diff --git a/mflix/README-JAVA-SPRING.md b/mflix/README-JAVA-SPRING.md index d561d10..652ef18 100644 --- a/mflix/README-JAVA-SPRING.md +++ b/mflix/README-JAVA-SPRING.md @@ -58,7 +58,7 @@ Edit the `.env` file and set your MongoDB connection string: ```env # MongoDB Connection -MONGODB_URI=mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority" # Voyage AI Configuration (optional - required for Vector Search) VOYAGE_API_KEY=your_voyage_api_key diff --git a/mflix/README-NODE-EXPRESS.md b/mflix/README-NODE-EXPRESS.md index d7c013a..64835bc 100644 --- a/mflix/README-NODE-EXPRESS.md +++ b/mflix/README-NODE-EXPRESS.md @@ -58,7 +58,7 @@ Edit the `.env` file and set your MongoDB connection string: ```env # MongoDB Connection # Replace with your MongoDB Atlas connection string or local MongoDB URI -MONGODB_URI=mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority" # Voyage AI Configuration # API key for Voyage AI embedding model (required for Vector Search) diff --git a/mflix/README-PYTHON-FASTAPI.md b/mflix/README-PYTHON-FASTAPI.md index 38168be..6419bac 100644 --- a/mflix/README-PYTHON-FASTAPI.md +++ b/mflix/README-PYTHON-FASTAPI.md @@ -61,7 +61,7 @@ Edit the `.env` file and set your MongoDB connection string: ```env # MongoDB Connection -MONGODB_URI=mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority" # Voyage AI Configuration (optional - required for Vector Search) VOYAGE_API_KEY=your_voyage_api_key diff --git a/mflix/server/java-spring/.env.example b/mflix/server/java-spring/.env.example index 96c3555..e6b4861 100644 --- a/mflix/server/java-spring/.env.example +++ b/mflix/server/java-spring/.env.example @@ -1,6 +1,6 @@ # MongoDB Connection # Replace with your MongoDB Atlas connection string or local MongoDB URI -MONGODB_URI=mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority" # OPTIONAL: Voyage AI Configuration (required for Vector Search) # Get your API key from https://www.voyageai.com/ diff --git a/mflix/server/java-spring/pom.xml b/mflix/server/java-spring/pom.xml index a7b3ae8..a814870 100644 --- a/mflix/server/java-spring/pom.xml +++ b/mflix/server/java-spring/pom.xml @@ -8,7 +8,7 @@ org.springframework.boot spring-boot-starter-parent - 3.5.7 + 3.5.13 @@ -58,7 +58,7 @@ org.projectlombok lombok - true + provided @@ -88,6 +88,20 @@ test + + + org.testcontainers + junit-jupiter + test + + + + + org.testcontainers + mongodb + test + + com.fasterxml.jackson.core @@ -118,11 +132,18 @@ - + org.apache.maven.plugins maven-compiler-plugin + + + org.projectlombok + lombok + ${lombok.version} + + -Xlint:-options 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 815c248..c654a81 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 @@ -9,6 +9,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; import org.springframework.lang.NonNull; @@ -119,4 +120,9 @@ public MongoDatabase mongoDatabase() { return client.getDatabase(databaseName); } + + @Bean + public MongoCustomConversions customConversions() { + return MongoCustomConversions.create(MongoCustomConversions.MongoConverterConfigurationAdapter::useNativeDriverJavaTimeCodecs); + } } diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java index 519f13b..0ea67d7 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java @@ -18,7 +18,6 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import java.time.Instant; import java.util.List; import java.util.Map; import org.bson.Document; @@ -103,10 +102,8 @@ public ResponseEntity>> getAllMovies( String message = "Found " + movies.size() + " movies"; SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message(message) .data(movies) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -122,10 +119,8 @@ public ResponseEntity>> getDistinctGenres() { List genres = movieService.getDistinctGenres(); SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message("Found " + genres.size() + " distinct genres") .data(genres) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -142,10 +137,8 @@ public ResponseEntity> getMovieById( Movie movie = movieService.getMovieById(id); SuccessResponse response = SuccessResponse.builder() - .success(true) .message("Movie retrieved successfully") .data(movie) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -162,10 +155,8 @@ public ResponseEntity> createMovie( Movie movie = movieService.createMovie(request); SuccessResponse response = SuccessResponse.builder() - .success(true) - .message("Movie '" + request.getTitle() + "' created successfully") + .message("Movie '" + request.title() + "' created successfully") .data(movie) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -182,10 +173,8 @@ public ResponseEntity> createMoviesBatch( BatchInsertResponse result = movieService.createMoviesBatch(requests); SuccessResponse response = SuccessResponse.builder() - .success(true) - .message("Successfully created " + result.getInsertedCount() + " movies") + .message("Successfully created " + result.insertedCount() + " movies") .data(result) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -210,10 +199,8 @@ public ResponseEntity> updateMovie( Movie movie = movieService.updateMovie(id, request); SuccessResponse response = SuccessResponse.builder() - .success(true) .message("Movie updated successfully") .data(movie) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -235,11 +222,9 @@ public ResponseEntity> updateMoviesBatch( BatchUpdateResponse result = movieService.updateMoviesBatch(filter, update); SuccessResponse response = SuccessResponse.builder() - .success(true) - .message("Update operation completed. Matched " + result.getMatchedCount() + - " documents, modified " + result.getModifiedCount() + " documents.") + .message("Update operation completed. Matched " + result.matchedCount() + + " documents, modified " + result.modifiedCount() + " documents.") .data(result) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -257,10 +242,8 @@ public ResponseEntity> findAndDeleteMovie( Movie movie = movieService.findAndDeleteMovie(id); SuccessResponse response = SuccessResponse.builder() - .success(true) .message("Movie found and deleted successfully") .data(movie) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -277,10 +260,8 @@ public ResponseEntity> deleteMovie( DeleteResponse result = movieService.deleteMovie(id); SuccessResponse response = SuccessResponse.builder() - .success(true) .message("Movie deleted successfully") .data(result) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -301,10 +282,8 @@ public ResponseEntity> deleteMoviesBatch( DeleteResponse result = movieService.deleteMoviesBatch(filter); SuccessResponse response = SuccessResponse.builder() - .success(true) - .message("Delete operation completed. Removed " + result.getDeletedCount() + " documents.") + .message("Delete operation completed. Removed " + result.deletedCount() + " documents.") .data(result) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -328,7 +307,7 @@ public ResponseEntity>> getMoviesW // Calculate total comments across all movies int totalComments = results.stream() - .mapToInt(result -> result.getTotalComments() != null ? result.getTotalComments() : 0) + .mapToInt(result -> result.totalComments() != null ? result.totalComments() : 0) .sum(); String message = movieId != null @@ -338,10 +317,8 @@ public ResponseEntity>> getMoviesW SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message(message) .data(results) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -359,10 +336,8 @@ public ResponseEntity>> getMoviesByYear SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message(String.format("Aggregated statistics for %d years", results.size())) .data(results) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -382,10 +357,8 @@ public ResponseEntity>> getDirect SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message(String.format("Found %d directors with most movies", results.size())) .data(results) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -440,10 +413,8 @@ public ResponseEntity> searchMovies( .build(); SuccessResponse response = SuccessResponse.builder() - .success(true) .message(String.format("Found %d movies matching the search criteria", movies.size())) .data(searchResponse) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -465,10 +436,8 @@ public ResponseEntity>> vectorSearchMov List results = movieService.vectorSearchMovies(q, limit); SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message(String.format("Found %d similar movies for query: '%s'", results.size(), q)) .data(results) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); @@ -489,10 +458,8 @@ public ResponseEntity>> findSimilarMovies( List movies = movieService.findSimilarMovies(movieId, limit); SuccessResponse> response = SuccessResponse.>builder() - .success(true) .message(String.format("Found %d similar movies", movies.size())) .data(movies) - .timestamp(Instant.now().toString()) .build(); return ResponseEntity.ok(response); 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 3776dae..101b5c3 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 @@ -29,7 +29,6 @@ public ResponseEntity handleResourceNotFoundException( logger.error("Resource not found: {}", ex.getMessage()); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message(ex.getMessage()) .error(ErrorResponse.ErrorDetails.builder() .message(ex.getMessage()) @@ -47,7 +46,6 @@ public ResponseEntity handleValidationException( logger.error("Validation error: {}", ex.getMessage()); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message("Validation failed") .error(ErrorResponse.ErrorDetails.builder() .message(ex.getMessage()) @@ -67,7 +65,6 @@ public ResponseEntity handleMissingServletRequestParameter( String message = String.format("Required parameter '%s' is missing", ex.getParameterName()); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message(message) .error(ErrorResponse.ErrorDetails.builder() .message(message) @@ -85,7 +82,6 @@ public ResponseEntity handleServiceUnavailableException( logger.error("Service unavailable: {}", ex.getMessage()); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message(ex.getMessage()) .error(ErrorResponse.ErrorDetails.builder() .message(ex.getMessage()) @@ -103,7 +99,6 @@ public ResponseEntity handleVoyageAuthException( logger.error("Voyage AI authentication error: {}", ex.getMessage()); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message(ex.getMessage()) .error(ErrorResponse.ErrorDetails.builder() .message(ex.getMessage()) @@ -122,7 +117,6 @@ public ResponseEntity handleVoyageAPIException( 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()) @@ -140,7 +134,6 @@ public ResponseEntity handleDatabaseOperationException( logger.error("Database operation error: {}", ex.getMessage()); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message("Database operation failed") .error(ErrorResponse.ErrorDetails.builder() .message(ex.getMessage()) @@ -168,7 +161,6 @@ public ResponseEntity handleMongoWriteException( } ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message(message) .error(ErrorResponse.ErrorDetails.builder() .message(message) @@ -187,7 +179,6 @@ public ResponseEntity handleGenericException( logger.error("Unexpected error occurred", ex); ErrorResponse errorResponse = ErrorResponse.builder() - .success(false) .message(ex.getMessage() != null ? ex.getMessage() : "Internal server error") .error(ErrorResponse.ErrorDetails.builder() .message(ex.getMessage() != null ? ex.getMessage() : "Internal server error") diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java index b896b4e..d5f1fdf 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/Movie.java @@ -1,13 +1,19 @@ package com.mongodb.samplemflix.model; import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.Date; +import java.time.Instant; +import java.time.LocalDate; import java.util.List; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; /** @@ -18,15 +24,16 @@ * for awards, IMDB ratings, and Tomatoes ratings. * *

Note: We use Lombok annotations to reduce boilerplate code: - * - @Data: Generates getters, setters, toString, equals, and hashCode + * - @Getter @Setter @ToString @EqualsAndHashCode: Generates getters, setters, toString, equals, and hashCode * - @Builder: Provides a fluent builder pattern for object construction - * - @NoArgsConstructor: Generates a no-argument constructor (required by MongoDB driver) - * - @AllArgsConstructor: Generates a constructor with all fields */ -@Data +@Getter +@Setter +@ToString(onlyExplicitlyIncluded = true) +@EqualsAndHashCode(onlyExplicitlyIncluded = true) @Builder -@NoArgsConstructor -@AllArgsConstructor +@AllArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping +@NoArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping @Document(collection = "movies") public class Movie { @@ -77,16 +84,21 @@ private Fields() { * Can be null for new documents (MongoDB will generate it). */ @JsonProperty("_id") + @Id + @ToString.Include + @EqualsAndHashCode.Include private ObjectId id; /** * Movie title (required field). */ + @ToString.Include private String title; /** * Release year. */ + @ToString.Include private Integer year; /** @@ -100,9 +112,14 @@ private Fields() { private String fullplot; /** - * Release date. + * Release date as a calendar date (no time-of-day or time zone). + * + *

A movie release date is a "date only" concept (e.g. "1999-03-31"), not a specific + * moment in time. We use {@link LocalDate} because it represents exactly that: a date + * without time-of-day or time zone information. Spring Data MongoDB maps this via the + * Jsr310 {@code LocalDateCodec}. */ - private Date released; + private LocalDate released; /** * Runtime in minutes. @@ -177,10 +194,11 @@ private Fields() { /** * Nested class representing awards information. */ - @Data + @Getter + @Setter @Builder - @NoArgsConstructor - @AllArgsConstructor + @AllArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping + @NoArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping public static class Awards { /** * Number of awards won. @@ -201,10 +219,11 @@ public static class Awards { /** * Nested class representing IMDB rating information. */ - @Data + @Getter + @Setter @Builder - @NoArgsConstructor - @AllArgsConstructor + @AllArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping + @NoArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping public static class Imdb { /** * IMDB rating (0.0 to 10.0). @@ -225,10 +244,11 @@ public static class Imdb { /** * Nested class representing Rotten Tomatoes rating information. */ - @Data + @Getter + @Setter @Builder - @NoArgsConstructor - @AllArgsConstructor + @AllArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping + @NoArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping public static class Tomatoes { /** * Viewer ratings information. @@ -256,17 +276,21 @@ public static class Tomatoes { private String production; /** - * Last updated date. + * Timestamp of the last update to Tomatoes ratings. + * + *

Stored as BSON DateTime in MongoDB. Uses {@link Instant} for an immutable, + * UTC-only representation of this point-in-time event. */ - private Date lastUpdated; + private Instant lastUpdated; /** * Nested class for viewer ratings. */ - @Data + @Getter + @Setter @Builder - @NoArgsConstructor - @AllArgsConstructor + @AllArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping + @NoArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping public static class Viewer { /** * Viewer rating (0.0 to 5.0). @@ -287,10 +311,11 @@ public static class Viewer { /** * Nested class for critic ratings. */ - @Data + @Getter + @Setter @Builder - @NoArgsConstructor - @AllArgsConstructor + @AllArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping + @NoArgsConstructor(access = AccessLevel.PROTECTED) // needed for Spring Data and MongoDB mapping public static class Critic { /** * Critic rating (0.0 to 5.0). diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java index 94edcd0..becaaeb 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchInsertResponse.java @@ -1,19 +1,12 @@ package com.mongodb.samplemflix.model.dto; import java.util.Collection; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; import org.bson.BsonValue; /** * Response DTO for batch insert operations. */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class BatchInsertResponse { - private int insertedCount; - private Collection insertedIds; -} +public record BatchInsertResponse ( + int insertedCount, + Collection insertedIds) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java index 354bdbc..8558aa4 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/BatchUpdateResponse.java @@ -1,17 +1,8 @@ package com.mongodb.samplemflix.model.dto; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - /** * Response DTO for batch update operations. */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class BatchUpdateResponse { - private long matchedCount; - private long modifiedCount; -} - +public record BatchUpdateResponse ( + long matchedCount, + long modifiedCount) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java index c333064..cb91792 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/CreateMovieRequest.java @@ -2,88 +2,81 @@ import jakarta.validation.constraints.NotBlank; import java.util.List; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Data Transfer Object for creating a new movie. * *

This DTO is used for POST /api/movies requests. * It includes validation annotations to ensure required fields are present. - * Only the title field is required; all other fields are optional. + * Only the title field is required, all other fields are optional. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class CreateMovieRequest { +public record CreateMovieRequest ( /** * Movie title (required). * Must not be blank. */ @NotBlank(message = "Title is required") - private String title; + String title, /** * Release year (optional). */ - private Integer year; + Integer year, /** * Short plot summary (optional). */ - private String plot; + String plot, /** * Full plot description (optional). */ - private String fullplot; + String fullplot, /** * List of genres (optional). */ - private List genres; + List genres, /** * List of directors (optional). */ - private List directors; + List directors, /** * List of writers (optional). */ - private List writers; + List writers, /** * List of cast members (optional). */ - private List cast; + List cast, /** * List of countries (optional). */ - private List countries; + List countries, /** * List of languages (optional). */ - private List languages; + List languages, /** * Movie rating (optional). */ - private String rated; + String rated, /** * Runtime in minutes (optional). */ - private Integer runtime; + Integer runtime, /** * Poster image URL (optional). */ - private String poster; -} + String poster) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java index b7ddc85..eecf120 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DeleteResponse.java @@ -1,16 +1,8 @@ package com.mongodb.samplemflix.model.dto; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - /** * Response DTO for delete operations. */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DeleteResponse { - private long deletedCount; -} +public record DeleteResponse ( + long deletedCount) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DirectorStatisticsResult.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DirectorStatisticsResult.java index 6bf04dc..591c8b7 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DirectorStatisticsResult.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/DirectorStatisticsResult.java @@ -1,10 +1,8 @@ package com.mongodb.samplemflix.model.dto; import com.fasterxml.jackson.annotation.JsonInclude; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.With; /** * DTO for director statistics aggregation result. @@ -12,26 +10,22 @@ *

This class represents the result of the reportingByDirectors aggregation * which finds directors with the most movies and their statistics. */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class DirectorStatisticsResult { - +@Builder +public record DirectorStatisticsResult ( /** * Director name. */ - private String director; + String director, /** * Number of movies directed by this director. */ - private Integer movieCount; + Integer movieCount, /** * Average IMDB rating of this director's movies. */ - private Double averageRating; -} + @With + Double averageRating) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java index dc0ea46..8a4a1a3 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchQuery.java @@ -1,9 +1,6 @@ package com.mongodb.samplemflix.model.dto; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Data Transfer Object for movie search query parameters. @@ -11,57 +8,53 @@ *

This DTO is used to parse and validate query parameters for GET /api/movies requests. * It supports full-text search, filtering by genre/year/rating, sorting, and pagination. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class MovieSearchQuery { +public record MovieSearchQuery ( /** * Full-text search query. * Searches across plot, title, and fullplot fields using MongoDB text index. */ - private String q; + String q, /** * Filter by genre (case-insensitive partial match). */ - private String genre; + String genre, /** * Filter by exact year. */ - private Integer year; + Integer year, /** * Minimum IMDB rating (inclusive). */ - private Double minRating; + Double minRating, /** * Maximum IMDB rating (inclusive). */ - private Double maxRating; + Double maxRating, /** * Number of results to return (default: 20, max: 100). */ - private Integer limit; + Integer limit, /** * Number of results to skip for pagination (default: 0). */ - private Integer skip; + Integer skip, /** * Field to sort by (e.g., "title", "year", "imdb.rating"). * Default: "title" */ - private String sortBy; + String sortBy, /** * Sort order: "asc" or "desc". * Default: "asc" */ - private String sortOrder; -} + String sortOrder) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchRequest.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchRequest.java index f309079..7d1d47a 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchRequest.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieSearchRequest.java @@ -1,9 +1,6 @@ package com.mongodb.samplemflix.model.dto; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Data Transfer Object for MongoDB Search query parameters. @@ -11,53 +8,50 @@ *

This DTO is used to parse and validate query parameters for GET /api/movies/search requests. * It supports searching across multiple fields using MongoDB Search with compound operators. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class MovieSearchRequest { +public record MovieSearchRequest ( /** * Search query for the plot field. * Uses phrase operator for exact phrase matching. */ - private String plot; + String plot, /** * Search query for the fullplot field. * Uses phrase operator for exact phrase matching. */ - private String fullplot; + String fullplot, /** * Search query for the directors field. * Uses text operator with fuzzy matching (maxEdits=1, prefixLength=5). */ - private String directors; + String directors, /** * Search query for the writers field. * Uses text operator with fuzzy matching (maxEdits=1, prefixLength=5). */ - private String writers; + String writers, /** * Search query for the cast field. * Uses text operator with fuzzy matching (maxEdits=1, prefixLength=5). */ - private String cast; + String cast, /** * Maximum number of results to return. * Default: 20, Range: 1-100 */ - private Integer limit; + Integer limit, /** * Number of results to skip for pagination. * Default: 0, Minimum: 0 */ - private Integer skip; + Integer skip, /** * Compound search operator to use. @@ -71,7 +65,7 @@ public class MovieSearchRequest { *

  • filter - Clauses must match but don't affect scoring
  • * */ - private String searchOperator; + String searchOperator) { /** * Checks if at least one search field is provided. diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieWithCommentsResult.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieWithCommentsResult.java index 62e70c4..37c4f8d 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieWithCommentsResult.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MovieWithCommentsResult.java @@ -1,12 +1,9 @@ package com.mongodb.samplemflix.model.dto; import com.fasterxml.jackson.annotation.JsonInclude; -import java.util.Date; +import java.time.Instant; import java.util.List; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * DTO for movies with their most recent comments aggregation result. @@ -14,95 +11,94 @@ *

    This class represents the result of the reportingByComments aggregation * which joins movies with their comments and returns movies with the most comments. */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class MovieWithCommentsResult { +@Builder +public record MovieWithCommentsResult ( /** * Movie ID as string. */ - private String _id; + String _id, /** * Movie title. */ - private String title; + String title, /** * Release year. */ - private Integer year; + Integer year, /** * Short plot summary. */ - private String plot; + String plot, /** * Poster image URL. */ - private String poster; + String poster, /** * List of genres. */ - private List genres; + List genres, /** * IMDB rating (0.0 to 10.0). */ - private Double imdbRating; + Double imdbRating, /** * Most recent comments for this movie. */ - private List recentComments; + List recentComments, /** * Total number of comments for this movie. */ - private Integer totalComments; + Integer totalComments, /** - * Date of the most recent comment. + * Timestamp of the most recent comment as a UTC instant. + * + *

    Uses {@link Instant} for an immutable, unambiguous UTC representation. + * BSON DateTime values are converted via {@code Date.toInstant()}. */ - private Date mostRecentCommentDate; + Instant mostRecentCommentDate) { /** - * Nested class for comment information. + * Nested record for comment information. */ - @Data @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class CommentInfo { + public record CommentInfo ( /** * Comment ID as string. */ - private String id; + String id, /** * Commenter name. */ - private String name; + String name, /** * Commenter email. */ - private String email; + String email, /** * Comment text. */ - private String text; + String text, /** - * Comment date. + * Comment timestamp as a UTC instant. + * + *

    Stored as BSON DateTime in MongoDB. Uses {@link Instant} for immutability + * and unambiguous UTC semantics. */ - private Date date; - } + Instant date) {} } diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MoviesByYearResult.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MoviesByYearResult.java index 1ab4da2..13abc2b 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MoviesByYearResult.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/MoviesByYearResult.java @@ -1,10 +1,8 @@ package com.mongodb.samplemflix.model.dto; import com.fasterxml.jackson.annotation.JsonInclude; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.With; /** * DTO for movies aggregated by year with statistics. @@ -12,41 +10,38 @@ *

    This class represents the result of the reportingByYear aggregation * which groups movies by release year and calculates statistics per year. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class MoviesByYearResult { +public record MoviesByYearResult ( /** * Release year. */ - private Integer year; + Integer year, /** * Number of movies released in this year. */ - private Integer movieCount; + Integer movieCount, /** * Average IMDB rating for movies in this year. */ - private Double averageRating; + @With + Double averageRating, /** * Highest IMDB rating for movies in this year. */ - private Double highestRating; + Double highestRating, /** * Lowest IMDB rating for movies in this year. */ - private Double lowestRating; + Double lowestRating, /** * Total number of IMDB votes for all movies in this year. */ - private Long totalVotes; -} + Long totalVotes) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/SearchMoviesResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/SearchMoviesResponse.java index 8dd2343..b33bec6 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/SearchMoviesResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/SearchMoviesResponse.java @@ -2,10 +2,7 @@ import com.mongodb.samplemflix.model.Movie; import java.util.List; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Response wrapper for movie search results. @@ -13,20 +10,16 @@ *

    This DTO wraps the search results with pagination metadata, * matching the structure returned by the Python backend. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class SearchMoviesResponse { +public record SearchMoviesResponse ( /** * List of movies matching the search criteria. */ - private List movies; + List movies, /** * Total count of movies matching the search criteria. */ - private Integer totalCount; -} + Integer totalCount) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java index f3a674f..2f692bf 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/UpdateMovieRequest.java @@ -1,10 +1,7 @@ package com.mongodb.samplemflix.model.dto; import java.util.List; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Data Transfer Object for updating an existing movie. @@ -13,74 +10,70 @@ * All fields are optional since partial updates are allowed. * Any field that is null will not be updated in the database. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class UpdateMovieRequest { +public record UpdateMovieRequest ( /** * Movie title (optional). */ - private String title; + String title, /** * Release year (optional). */ - private Integer year; + Integer year, /** * Short plot summary (optional). */ - private String plot; + String plot, /** * Full plot description (optional). */ - private String fullplot; + String fullplot, /** * List of genres (optional). */ - private List genres; + List genres, /** * List of directors (optional). */ - private List directors; + List directors, /** * List of writers (optional). */ - private List writers; + List writers, /** * List of cast members (optional). */ - private List cast; + List cast, /** * List of countries (optional). */ - private List countries; + List countries, /** * List of languages (optional). */ - private List languages; + List languages, /** * Movie rating (optional). */ - private String rated; + String rated, /** * Runtime in minutes (optional). */ - private Integer runtime; + Integer runtime, /** * Poster image URL (optional). */ - private String poster; -} + String poster) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/VectorSearchResult.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/VectorSearchResult.java index 00dc38e..e1662cd 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/VectorSearchResult.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/dto/VectorSearchResult.java @@ -1,9 +1,7 @@ package com.mongodb.samplemflix.model.dto; -import lombok.AllArgsConstructor; +import java.util.List; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Data Transfer Object for vector search results. @@ -11,55 +9,51 @@ *

    This DTO represents the result of a MongoDB Vector Search query, * containing the movie information and similarity score. */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor -public class VectorSearchResult { - +public record VectorSearchResult ( + /** * Movie ObjectId as a string. */ - private String id; - + String id, + /** * Movie title. */ - private String title; - + String title, + /** * Movie plot summary. */ - private String plot; - + String plot, + /** * Movie poster URL. */ - private String poster; - + String poster, + /** * Movie release year. */ - private Integer year; - + Integer year, + /** * Movie genres. */ - private java.util.List genres; - + List genres, + /** * Movie directors. */ - private java.util.List directors; - + List directors, + /** * Movie cast members. */ - private java.util.List cast; - + List cast, + /** * Vector search similarity score (0.0 to 1.0, higher = more similar). */ - private Double score; -} + Double score) {} diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java index 1374efb..3427244 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ApiResponse.java @@ -17,12 +17,12 @@ public interface ApiResponse { * * @return true for successful responses, false for error responses */ - boolean isSuccess(); + boolean success(); /** - * Gets the timestamp when the response was generated. + * Returns the timestamp when the response was generated. * * @return ISO 8601 formatted timestamp string */ - String getTimestamp(); + String timestamp(); } diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java index 6fab419..2f028e1 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/ErrorResponse.java @@ -2,10 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.time.Instant; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Error response wrapper for API error responses. @@ -23,56 +20,53 @@ * timestamp: string * } */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class ErrorResponse implements ApiResponse { +public record ErrorResponse ( /** * Always false for error responses. */ - @Builder.Default - private boolean success = false; + boolean success, /** * High-level error message. */ - private String message; + String message, /** * Detailed error information. */ - private ErrorDetails error; + ErrorDetails error, /** * ISO 8601 timestamp when the error occurred. */ - @Builder.Default - private String timestamp = Instant.now().toString(); + String timestamp) implements ApiResponse { + + // Partial builder declaration to provide defaults for records (like @Builder.Default for classes) + public static class ErrorResponseBuilder { + private boolean success = false; + private String timestamp = Instant.now().toString(); + } /** * Nested class for detailed error information. */ - @Data @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class ErrorDetails { + public record ErrorDetails ( /** * Detailed error message. */ - private String message; + String message, /** * Error code (e.g., "VALIDATION_ERROR", "NOT_FOUND"). */ - private String code; + String code, /** * Additional error details (optional). */ - private Object details; - } + Object details) {} } diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java index 69550ee..34393cb 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/model/response/SuccessResponse.java @@ -2,10 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.time.Instant; -import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; /** * Success response wrapper for API responses. @@ -21,66 +18,63 @@ * pagination?: { page, limit, total, pages } * } */ -@Data @Builder -@NoArgsConstructor -@AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class SuccessResponse implements ApiResponse { +public record SuccessResponse ( /** * Always true for success responses. */ - @Builder.Default - private boolean success = true; + boolean success, /** * Optional success message. */ - private String message; + String message, /** * The response data (generic type). */ - private T data; + T data, /** * ISO 8601 timestamp when the response was generated. */ - @Builder.Default - private String timestamp = Instant.now().toString(); + String timestamp, /** * Optional pagination metadata (for list responses). */ - private Pagination pagination; + Pagination pagination) implements ApiResponse { + + // Partial builder declaration to provide defaults for records (like @Builder.Default for classes) + public static class SuccessResponseBuilder { + private boolean success = true; + private String timestamp = Instant.now().toString(); + } /** * Nested class for pagination metadata. */ - @Data @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class Pagination { + public record Pagination ( /** * Current page number (1-based). */ - private int page; + int page, /** * Number of items per page. */ - private int limit; + int limit, /** * Total number of items. */ - private long total; + long total, /** * Total number of pages. */ - private int pages; - } + int pages) {} } 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 3ccb9d5..9aec741 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 @@ -77,11 +77,11 @@ public MovieServiceImpl(MovieRepository movieRepository, MongoTemplate mongoTemp public List getAllMovies(MovieSearchQuery query) { Query mongoQuery = buildQuery(query); - int limit = Math.clamp(query.getLimit() != null ? query.getLimit() : 20, 1, 100); - int skip = Math.max(query.getSkip() != null ? query.getSkip() : 0, 0); + int limit = Math.clamp(query.limit() != null ? query.limit() : 20, 1, 100); + int skip = Math.max(query.skip() != null ? query.skip() : 0, 0); mongoQuery.skip(skip).limit(limit); - mongoQuery.with(buildSort(query.getSortBy(), query.getSortOrder())); + mongoQuery.with(buildSort(query.sortBy(), query.sortOrder())); return mongoTemplate.find(mongoQuery, Movie.class); } @@ -116,24 +116,24 @@ public Movie getMovieById(String id) { @Override public Movie createMovie(CreateMovieRequest request) { - if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + if (request.title() == null || request.title().trim().isEmpty()) { throw new ValidationException("Title is required"); } Movie movie = Movie.builder() - .title(request.getTitle()) - .year(request.getYear()) - .plot(request.getPlot()) - .fullplot(request.getFullplot()) - .genres(request.getGenres()) - .directors(request.getDirectors()) - .writers(request.getWriters()) - .cast(request.getCast()) - .countries(request.getCountries()) - .languages(request.getLanguages()) - .rated(request.getRated()) - .runtime(request.getRuntime()) - .poster(request.getPoster()) + .title(request.title()) + .year(request.year()) + .plot(request.plot()) + .fullplot(request.fullplot()) + .genres(request.genres()) + .directors(request.directors()) + .writers(request.writers()) + .cast(request.cast()) + .countries(request.countries()) + .languages(request.languages()) + .rated(request.rated()) + .runtime(request.runtime()) + .poster(request.poster()) .build(); // Spring Data MongoDB's save() method inserts or updates @@ -148,26 +148,26 @@ public BatchInsertResponse createMoviesBatch(List requests) for (int i = 0; i < requests.size(); i++) { CreateMovieRequest request = requests.get(i); - if (request.getTitle() == null || request.getTitle().trim().isEmpty()) { + if (request.title() == null || request.title().trim().isEmpty()) { throw new ValidationException("Movie at index " + i + ": Title is required"); } } List movies = requests.stream() .map(request -> Movie.builder() - .title(request.getTitle()) - .year(request.getYear()) - .plot(request.getPlot()) - .fullplot(request.getFullplot()) - .genres(request.getGenres()) - .directors(request.getDirectors()) - .writers(request.getWriters()) - .cast(request.getCast()) - .countries(request.getCountries()) - .languages(request.getLanguages()) - .rated(request.getRated()) - .runtime(request.getRuntime()) - .poster(request.getPoster()) + .title(request.title()) + .year(request.year()) + .plot(request.plot()) + .fullplot(request.fullplot()) + .genres(request.genres()) + .directors(request.directors()) + .writers(request.writers()) + .cast(request.cast()) + .countries(request.countries()) + .languages(request.languages()) + .rated(request.rated()) + .runtime(request.runtime()) + .poster(request.poster()) .build()) .toList(); @@ -302,30 +302,30 @@ private Query buildQuery(MovieSearchQuery query) { Query mongoQuery = new Query(); // Text search - if (query.getQ() != null && !query.getQ().trim().isEmpty()) { - TextCriteria textCriteria = TextCriteria.forDefaultLanguage().matching(query.getQ()); + if (query.q() != null && !query.q().trim().isEmpty()) { + TextCriteria textCriteria = TextCriteria.forDefaultLanguage().matching(query.q()); mongoQuery.addCriteria(textCriteria); } // Genre filter (case-insensitive regex) - if (query.getGenre() != null && !query.getGenre().trim().isEmpty()) { + if (query.genre() != null && !query.genre().trim().isEmpty()) { mongoQuery.addCriteria(Criteria.where(Movie.Fields.GENRES) - .regex(Pattern.compile(query.getGenre(), Pattern.CASE_INSENSITIVE))); + .regex(Pattern.compile(query.genre(), Pattern.CASE_INSENSITIVE))); } // Year filter - if (query.getYear() != null) { - mongoQuery.addCriteria(Criteria.where(Movie.Fields.YEAR).is(query.getYear())); + if (query.year() != null) { + mongoQuery.addCriteria(Criteria.where(Movie.Fields.YEAR).is(query.year())); } // Rating range filter - if (query.getMinRating() != null || query.getMaxRating() != null) { + if (query.minRating() != null || query.maxRating() != null) { Criteria ratingCriteria = Criteria.where(Movie.Fields.IMDB_RATING); - if (query.getMinRating() != null) { - ratingCriteria = ratingCriteria.gte(query.getMinRating()); + if (query.minRating() != null) { + ratingCriteria = ratingCriteria.gte(query.minRating()); } - if (query.getMaxRating() != null) { - ratingCriteria = ratingCriteria.lte(query.getMaxRating()); + if (query.maxRating() != null) { + ratingCriteria = ratingCriteria.lte(query.maxRating()); } mongoQuery.addCriteria(ratingCriteria); } @@ -491,12 +491,11 @@ public List getMoviesByYearWithStats() { // Round average rating to 2 decimal places return results.getMappedResults().stream() - .peek(result -> { - if (result.getAverageRating() != null) { - result.setAverageRating( - Math.round(result.getAverageRating() * 100.0) / 100.0 - ); + .map(result -> { + if (result.averageRating() != null) { + return result.withAverageRating(Math.round(result.averageRating() * 100.0) / 100.0); } + return result; }) .collect(Collectors.toList()); } @@ -547,12 +546,11 @@ public List getDirectorsWithMostMovies(Integer limit) // Round average rating to 2 decimal places return results.getMappedResults().stream() - .peek(result -> { - if (result.getAverageRating() != null) { - result.setAverageRating( - Math.round(result.getAverageRating() * 100.0) / 100.0 - ); + .map(result -> { + if (result.averageRating() != null) { + return result.withAverageRating(Math.round(result.averageRating() * 100.0) / 100.0); } + return result; }) .collect(Collectors.toList()); } @@ -576,7 +574,7 @@ private MovieWithCommentsResult mapToMovieWithCommentsResult(Document doc) { .name(commentDoc.getString("name")) .email(commentDoc.getString("email")) .text(commentDoc.getString("text")) - .date(commentDoc.getDate("date")) + .date(commentDoc.get("date") != null ? commentDoc.getDate("date").toInstant() : null) .build()) .collect(Collectors.toList()); } @@ -600,7 +598,7 @@ private MovieWithCommentsResult mapToMovieWithCommentsResult(Document doc) { .imdbRating(imdbRating) .recentComments(recentComments) .totalComments(doc.getInteger("totalComments")) - .mostRecentCommentDate(doc.getDate("mostRecentCommentDate")) + .mostRecentCommentDate(doc.getDate("mostRecentCommentDate") != null ? doc.getDate("mostRecentCommentDate").toInstant() : null) .build(); } @@ -614,8 +612,8 @@ public List searchMovies(MovieSearchRequest searchRequest) { } // Validate search operator - String operator = searchRequest.getSearchOperator() != null ? - searchRequest.getSearchOperator() : "must"; + String operator = searchRequest.searchOperator() != null ? + searchRequest.searchOperator() : "must"; if (!operator.equals("must") && !operator.equals("should") && !operator.equals("mustNot") && !operator.equals("filter")) { @@ -627,27 +625,27 @@ public List searchMovies(MovieSearchRequest searchRequest) { // Validate and set defaults for pagination int resultLimit = Math.clamp( - searchRequest.getLimit() != null ? searchRequest.getLimit() : 20, 1, 100 + searchRequest.limit() != null ? searchRequest.limit() : 20, 1, 100 ); int resultSkip = Math.max( - searchRequest.getSkip() != null ? searchRequest.getSkip() : 0, 0 + searchRequest.skip() != null ? searchRequest.skip() : 0, 0 ); // Build search phrases list java.util.List searchPhrases = new java.util.ArrayList<>(); // Add plot search if provided (using phrase operator) - if (searchRequest.getPlot() != null && !searchRequest.getPlot().trim().isEmpty()) { + if (searchRequest.plot() != null && !searchRequest.plot().trim().isEmpty()) { searchPhrases.add(new Document("phrase", new Document() - .append("query", searchRequest.getPlot().trim()) + .append("query", searchRequest.plot().trim()) .append("path", Movie.Fields.PLOT) )); } // Add fullplot search if provided (using phrase operator) - if (searchRequest.getFullplot() != null && !searchRequest.getFullplot().trim().isEmpty()) { + if (searchRequest.fullplot() != null && !searchRequest.fullplot().trim().isEmpty()) { searchPhrases.add(new Document("phrase", new Document() - .append("query", searchRequest.getFullplot().trim()) + .append("query", searchRequest.fullplot().trim()) .append("path", Movie.Fields.FULLPLOT) )); } @@ -658,8 +656,8 @@ public List searchMovies(MovieSearchRequest searchRequest) { // 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()) { - String directorsQuery = searchRequest.getDirectors().trim(); + if (searchRequest.directors() != null && !searchRequest.directors().trim().isEmpty()) { + String directorsQuery = searchRequest.directors().trim(); searchPhrases.add(new Document("compound", new Document() .append("should", java.util.Arrays.asList( // Highest score: exact phrase match @@ -686,8 +684,8 @@ public List searchMovies(MovieSearchRequest searchRequest) { } // Add writers search if provided (see directors comments for compound scoring hierarchy) - if (searchRequest.getWriters() != null && !searchRequest.getWriters().trim().isEmpty()) { - String writersQuery = searchRequest.getWriters().trim(); + if (searchRequest.writers() != null && !searchRequest.writers().trim().isEmpty()) { + String writersQuery = searchRequest.writers().trim(); searchPhrases.add(new Document("compound", new Document() .append("should", java.util.Arrays.asList( new Document("phrase", new Document() @@ -710,8 +708,8 @@ public List searchMovies(MovieSearchRequest searchRequest) { } // Add cast search if provided (see directors comments for compound scoring hierarchy) - if (searchRequest.getCast() != null && !searchRequest.getCast().trim().isEmpty()) { - String castQuery = searchRequest.getCast().trim(); + if (searchRequest.cast() != null && !searchRequest.cast().trim().isEmpty()) { + String castQuery = searchRequest.cast().trim(); searchPhrases.add(new Document("compound", new Document() .append("should", java.util.Arrays.asList( new Document("phrase", new Document() diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java index 1add6ed..4eacf86 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java @@ -22,8 +22,8 @@ import com.mongodb.samplemflix.model.dto.UpdateMovieRequest; import com.mongodb.samplemflix.model.dto.VectorSearchResult; import com.mongodb.samplemflix.service.MovieService; +import java.time.Instant; import java.util.Arrays; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -343,7 +343,7 @@ void testGetMoviesWithMostComments_Success() throws Exception { .name("John Doe") .email("john@example.com") .text("Great movie!") - .date(new Date()) + .date(Instant.now()) .build(); MovieWithCommentsResult result = MovieWithCommentsResult.builder() @@ -356,7 +356,7 @@ void testGetMoviesWithMostComments_Success() throws Exception { .imdbRating(8.5) .recentComments(Arrays.asList(comment)) .totalComments(5) - .mostRecentCommentDate(new Date()) + .mostRecentCommentDate(Instant.now()) .build(); when(movieService.getMoviesWithMostRecentComments(anyInt(), isNull())).thenReturn(Arrays.asList(result)); diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBSearchIntegrationTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBSearchIntegrationTest.java index d1b65cb..b5e759a 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBSearchIntegrationTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBSearchIntegrationTest.java @@ -1,5 +1,6 @@ package com.mongodb.samplemflix.integration; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -7,10 +8,14 @@ import com.mongodb.client.MongoCollection; import com.mongodb.samplemflix.model.Movie; import com.mongodb.samplemflix.service.MovieService; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.TimeZone; +import lombok.extern.slf4j.Slf4j; +import org.bson.BsonDateTime; import org.bson.Document; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; @@ -20,27 +25,26 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; import org.springframework.test.context.ActiveProfiles; /** * Integration tests for MongoDB Search functionality. * - *

    These tests verify the MongoDB Search endpoints work correctly with a real MongoDB Atlas instance. - * The tests require: - *

      - *
    • A MongoDB Atlas cluster (not local MongoDB)
    • - *
    • MONGODB_URI environment variable pointing to Atlas
    • - *
    • MongoDB Search index creation and polling for readiness
    • - *
    + *

    These tests verify the MongoDB Search endpoints work correctly with a real + * MongoDB instance. By default, a MongoDBAtlasLocalContainer (Docker) is started + * automatically, providing a local Atlas environment with Search support. * - *

    Note: These tests are disabled by default and should only be run against a test Atlas cluster. - * To enable, set the environment variable ENABLE_SEARCH_TESTS=true + *

    To run against an external MongoDB Atlas cluster instead, set the + * {@code MONGODB_URI} environment variable. When set, no container is started. */ -@SpringBootTest +@SpringBootTest(classes = MongoDBTestContainersConfig.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @ActiveProfiles("test") @DisplayName("MongoDB Search Integration Tests") -class MongoDBSearchIntegrationTest { +@Slf4j +class MongoDBSearchIntegrationTest { @Autowired private MovieService movieService; @@ -57,13 +61,7 @@ class MongoDBSearchIntegrationTest { @BeforeAll void setUp() throws Exception { - // Skip tests if not running against Atlas - if (!isSearchEnabled()) { - System.out.println("Skipping MongoDB Search tests - ENABLE_SEARCH_TESTS not set"); - return; - } - - System.out.println("Setting up MongoDB Search integration tests..."); + log.info("Setting up MongoDB Search integration tests..."); // Create test data createTestMovies(); @@ -76,23 +74,19 @@ void setUp() throws Exception { // Wait a bit for the newly created documents to be indexed // MongoDB Search indexes documents asynchronously - System.out.println("Waiting for test documents to be indexed..."); + log.info("Waiting for test documents to be indexed..."); try { Thread.sleep(10000); // Wait 10 seconds for indexing } catch (InterruptedException e) { Thread.currentThread().interrupt(); } - System.out.println("MongoDB Search index is ready for testing"); + log.info("MongoDB Search index is ready for testing"); } @AfterAll void tearDown() { - if (!isSearchEnabled()) { - return; - } - - System.out.println("Cleaning up MongoDB Search test data..."); + log.info("Cleaning up MongoDB Search test data..."); // Clean up test movies if (!testMovieIds.isEmpty()) { @@ -109,11 +103,6 @@ void tearDown() { @Test @DisplayName("Should search movies by plot using MongoDB Search") void testSearchMoviesByPlot_Success() { - if (!isSearchEnabled()) { - System.out.println("Skipping test - Search not enabled"); - return; - } - // Act com.mongodb.samplemflix.model.dto.MovieSearchRequest searchRequest = com.mongodb.samplemflix.model.dto.MovieSearchRequest.builder() @@ -138,11 +127,6 @@ void testSearchMoviesByPlot_Success() { @Test @DisplayName("Should return empty list when no movies match search query") void testSearchMoviesByPlot_NoResults() { - if (!isSearchEnabled()) { - System.out.println("Skipping test - Search not enabled"); - return; - } - // Act - search for something that definitely doesn't exist com.mongodb.samplemflix.model.dto.MovieSearchRequest searchRequest = com.mongodb.samplemflix.model.dto.MovieSearchRequest.builder() @@ -161,11 +145,6 @@ void testSearchMoviesByPlot_NoResults() { @Test @DisplayName("Should respect limit parameter in search") void testSearchMoviesByPlot_WithLimit() { - if (!isSearchEnabled()) { - System.out.println("Skipping test - Search not enabled"); - return; - } - // Act com.mongodb.samplemflix.model.dto.MovieSearchRequest searchRequest = com.mongodb.samplemflix.model.dto.MovieSearchRequest.builder() @@ -184,11 +163,6 @@ void testSearchMoviesByPlot_WithLimit() { @Test @DisplayName("Should support pagination with skip parameter") void testSearchMoviesByPlot_WithPagination() { - if (!isSearchEnabled()) { - System.out.println("Skipping test - Search not enabled"); - return; - } - // Act - Get first page com.mongodb.samplemflix.model.dto.MovieSearchRequest firstPageRequest = com.mongodb.samplemflix.model.dto.MovieSearchRequest.builder() @@ -223,15 +197,58 @@ void testSearchMoviesByPlot_WithPagination() { } } - // ==================== HELPER METHODS ==================== + // ==================== RELEASED FIELD ROUND-TRIP TESTS ==================== + + @Test + @DisplayName("Should read BSON DateTime at midnight UTC as the correct LocalDate without date shift") + void testReleasedFieldRoundTrip_NoDateShift() { + // The test movies were inserted with known BSON DateTime values at midnight UTC: + // "Test Space Adventure" -> 2024-01-01T00:00:00Z (1704067200000) + // "Test Mystery Movie" -> 2024-03-31T00:00:00Z (1711843200000) + // "Test Adventure Quest" -> 2024-12-31T00:00:00Z (1735603200000) + // A JVM timezone west of UTC could shift these dates backward by one day + // (e.g. 2024-01-01 -> 2023-12-31). Cycle through multiple timezones to + // verify the native LocalDateCodec always interprets BSON DateTime in UTC. + + List timezones = List.of( + "America/New_York", + "America/Los_Angeles", + "Asia/Tokyo", + "Europe/London", + "Pacific/Auckland" + ); + + TimeZone originalTz = TimeZone.getDefault(); + try { + for (String zoneId : timezones) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + + Movie spaceAdventure = findTestMovieByTitle("Test Space Adventure"); + assertEquals(LocalDate.of(2024, 1, 1), spaceAdventure.getReleased(), + "2024-01-01T00:00:00Z shifted in " + zoneId); - private boolean isSearchEnabled() { - String enabled = System.getenv("ENABLE_SEARCH_TESTS"); - return "true".equalsIgnoreCase(enabled); + Movie mystery = findTestMovieByTitle("Test Mystery Movie"); + assertEquals(LocalDate.of(2024, 3, 31), mystery.getReleased(), + "2024-03-31T00:00:00Z shifted in " + zoneId); + + Movie adventureQuest = findTestMovieByTitle("Test Adventure Quest"); + assertEquals(LocalDate.of(2024, 12, 31), adventureQuest.getReleased(), + "2024-12-31T00:00:00Z shifted in " + zoneId); + } + } finally { + TimeZone.setDefault(originalTz); + } + } + + private Movie findTestMovieByTitle(String title) { + return mongoTemplate.findOne(new Query(Criteria.where("title").is(title)), Movie.class); } + // ==================== HELPER METHODS ==================== + + private void createTestMovies() { - System.out.println("Creating test movies..."); + log.info("Creating test movies..."); MongoCollection collection = mongoTemplate.getCollection("movies"); @@ -239,16 +256,19 @@ private void createTestMovies() { new Document() .append("title", "Test Space Adventure") .append("year", 2024) + .append("released", new BsonDateTime(1704067200000L)) // 1st of Jan 2024 in UTC .append("plot", "An epic space adventure across the galaxy") .append("genres", Arrays.asList("Sci-Fi", "Adventure")), new Document() .append("title", "Test Mystery Movie") .append("year", 2024) + .append("released", new BsonDateTime(1711843200000L)) // 31 of March 2024 in UTC .append("plot", "A detective solves a mysterious crime") .append("genres", Arrays.asList("Mystery", "Thriller")), new Document() .append("title", "Test Adventure Quest") .append("year", 2024) + .append("released", new BsonDateTime(1735603200000L)) // 31 December 2024 in UTC .append("plot", "Heroes embark on a dangerous adventure") .append("genres", Arrays.asList("Adventure", "Fantasy")) ); @@ -258,11 +278,11 @@ private void createTestMovies() { testMovieIds.add(movie.getObjectId("_id").toHexString()); }); - System.out.println("Created " + testMovieIds.size() + " test movies"); + log.info("Created {} test movies", testMovieIds.size()); } private void createSearchIndex() throws Exception { - System.out.println("Creating Search index..."); + log.info("Creating Search index..."); MongoCollection collection = mongoTemplate.getCollection("movies"); @@ -274,7 +294,7 @@ private void createSearchIndex() throws Exception { .anyMatch(idx -> SEARCH_INDEX_NAME.equals(idx.getString("name"))); if (indexExists) { - System.out.println("Search index already exists"); + log.info("Search index already exists"); return; } @@ -309,15 +329,15 @@ private void createSearchIndex() throws Exception { try { mongoTemplate.getDb().runCommand(createIndexCommand); - System.out.println("Search index creation initiated"); + log.info("Search index creation initiated"); } catch (Exception e) { - System.err.println("Error creating search index: " + e.getMessage()); + log.error("Error creating search index: {}", e.getMessage()); throw e; } } private void waitForSearchIndexReady() throws Exception { - System.out.println("Waiting for search index to be ready..."); + log.info("Waiting for search index to be ready..."); MongoCollection collection = mongoTemplate.getCollection("movies"); long startTime = System.currentTimeMillis(); @@ -334,10 +354,10 @@ private void waitForSearchIndexReady() throws Exception { if (searchIndex != null) { String status = searchIndex.getString("status"); - System.out.println("Index status: " + status); + log.info("Index status: {}", status); if ("READY".equals(status)) { - System.out.println("Search index is ready!"); + log.info("Search index is ready!"); return; } } diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBTestContainersConfig.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBTestContainersConfig.java new file mode 100644 index 0000000..a2b4fb4 --- /dev/null +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/MongoDBTestContainersConfig.java @@ -0,0 +1,42 @@ +package com.mongodb.samplemflix.integration; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.context.annotation.Conditional; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.lang.NonNull; +import org.springframework.test.context.DynamicPropertyRegistrar; +import org.testcontainers.mongodb.MongoDBAtlasLocalContainer; + +@TestConfiguration +public class MongoDBTestContainersConfig { + + // Note: @ServiceConnection cannot be used here because MongoDBAtlasLocalContainer + // extends GenericContainer, not MongoDBContainer. Spring Boot's auto-detection + // only recognizes the latter (fixed in Spring Boot v4). We use initMethod instead so Spring manages the + // full lifecycle (start on init, close/stop on context shutdown via AutoCloseable). + + @Bean(initMethod = "start") + @Conditional(MongoUriMissingCondition.class) + public MongoDBAtlasLocalContainer mongoDbContainer() { + return new MongoDBAtlasLocalContainer("mongodb/mongodb-atlas-local:8"); + } + + @Bean + @Conditional(MongoUriMissingCondition.class) + public DynamicPropertyRegistrar mongoDbProperties(MongoDBAtlasLocalContainer mongoDBContainer) { + return (registry) -> { + registry.add("spring.data.mongodb.uri", mongoDBContainer::getConnectionString); + }; + } + + static class MongoUriMissingCondition implements Condition { + @Override + public boolean matches(ConditionContext context, @NonNull AnnotatedTypeMetadata metadata) { + String uri = context.getEnvironment().getProperty("spring.data.mongodb.uri"); + return uri == null || uri.isBlank(); + } + } +} diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/README.md b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/README.md index a2fc932..dddce6d 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/README.md +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/integration/README.md @@ -4,36 +4,41 @@ This directory contains integration tests for MongoDB Search functionality. ## Overview -The `MongoDBSearchIntegrationTest` class tests the MongoDB Search endpoints with a real MongoDB Atlas instance. These tests verify that: +The `MongoDBSearchIntegrationTest` class tests the MongoDB Search endpoints with a real MongoDB instance. These tests verify that: 1. The MongoDB Search index is created correctly 2. The index becomes ready for use (using polling) 3. The `/search` endpoint returns correct results 4. Pagination works correctly 5. Empty results are handled properly +6. BSON DateTime values at midnight UTC round-trip correctly to `LocalDate` without date shift across JVM timezones ## Requirements -These tests require: +- **Docker** must be running (for the local Atlas container) +- Alternatively, set `MONGODB_URI` to use an external MongoDB Atlas cluster instead of Docker -- **MongoDB Atlas cluster** (not local MongoDB or Testcontainers) -- **MongoDB Search capability** enabled on the cluster -- **MONGODB_URI** environment variable pointing to your Atlas cluster -- **ENABLE_SEARCH_TESTS=true** environment variable to enable the tests +By default, tests spin up a `MongoDBAtlasLocalContainer` via Testcontainers, which provides a local Atlas environment with full Search support. No external cluster or special environment variables are needed. + +If neither Docker nor `MONGODB_URI` is available, the tests will fail. ## Running the Tests -### Enable the Tests +### Default (local Atlas container via Docker) -By default, these tests are **disabled** to prevent accidental runs against production databases. To enable them: +Just run the tests — Docker handles the rest: ```bash -export ENABLE_SEARCH_TESTS=true +# Run all integration tests +./mvnw test -Dtest=MongoDBSearchIntegrationTest + +# Run a specific test +./mvnw test -Dtest=MongoDBSearchIntegrationTest#testSearchMoviesByPlot_Success ``` -### Set MongoDB URI +### External Atlas Cluster (optional) -Make sure your `MONGODB_URI` environment variable points to a MongoDB Atlas cluster: +To run against an external MongoDB Atlas cluster instead of Docker, set the `MONGODB_URI` environment variable: ```bash export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net/sample_mflix?retryWrites=true&w=majority" @@ -42,22 +47,21 @@ export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net/sample_m Or use a `.env` file in the `server/java-spring` directory: ``` -MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net/sample_mflix?retryWrites=true&w=majority" ``` -### Run the Tests +When `MONGODB_URI` is set, no Docker container is started. -```bash -# Run all integration tests -./mvnw test -Dtest=MongoDBSearchIntegrationTest +## How the Tests Work -# Run a specific test -./mvnw test -Dtest=MongoDBSearchIntegrationTest#testSearchMoviesByPlot_Success -``` +### Test Configuration -## How the Tests Work +`MongoDBTestContainersConfig` is a `@TestConfiguration` that conditionally starts a `MongoDBAtlasLocalContainer`: -### 1. Index Creation and Polling +- If `spring.data.mongodb.uri` is empty or absent, it starts a local Atlas container and registers its connection string +- If `spring.data.mongodb.uri` is already set (via `MONGODB_URI` env var), no container is started + +### Index Creation and Polling The tests use a `@BeforeAll` method to: @@ -66,18 +70,21 @@ The tests use a `@BeforeAll` method to: 3. Poll the index status every 5 seconds until it's "READY" 4. Wait up to 120 seconds (2 minutes) for the index to be ready 5. Throw an exception if the index doesn't become ready in time +6. Wait an additional 10 seconds for asynchronous document indexing -### 2. Test Data Setup +### Test Data Setup -The tests create temporary test movies with known plot content: +The tests create temporary test movies with known plot content and `released` dates stored as BSON DateTime at midnight UTC: -- "An epic space adventure across the galaxy" -- "A detective solves a mysterious crime" -- "Heroes embark on a dangerous adventure" +| Title | Plot | Released (UTC) | +|---|---|---| +| Test Space Adventure | An epic space adventure across the galaxy | 2024-01-01 | +| Test Mystery Movie | A detective solves a mysterious crime | 2024-03-31 | +| Test Adventure Quest | Heroes embark on a dangerous adventure | 2024-12-31 | -These movies are used to verify search functionality. +These movies are used to verify both search functionality and correct `LocalDate` round-tripping of the `released` field. -### 3. Test Cleanup +### Test Cleanup The `@AfterAll` method removes the test movies after all tests complete. The search index is **not** deleted because: @@ -99,22 +106,17 @@ Verifies that the `limit` parameter correctly limits the number of results. ### testSearchMoviesByPlot_WithPagination Verifies that the `skip` parameter works for pagination and returns different results on different pages. -## Troubleshooting - -### Tests are Skipped - -If you see "Skipping Search tests - ENABLE_SEARCH_TESTS not set", make sure you've set the environment variable: +### testReleasedFieldRoundTrip_NoDateShift +Verifies that BSON DateTime values at midnight UTC are read as the correct `LocalDate` without date shift. The test temporarily switches the JVM default timezone through `America/New_York`, `America/Los_Angeles`, `Asia/Tokyo`, `Europe/London`, and `Pacific/Auckland` to ensure the native `LocalDateCodec` always interprets BSON DateTime in UTC. -```bash -export ENABLE_SEARCH_TESTS=true -``` +## Troubleshooting ### Index Creation Timeout If the tests fail with "Search index did not become ready within 120 seconds": -1. Check that your cluster has MongoDB Search enabled -2. Verify you're using a MongoDB Atlas cluster (not local MongoDB) +1. Ensure Docker is running and has enough resources +2. If using an external cluster, check that it has MongoDB Search enabled 3. Check the Atlas UI to see if the index is being created 4. Increase `MAX_INDEX_WAIT_SECONDS` if needed @@ -122,9 +124,9 @@ If the tests fail with "Search index did not become ready within 120 seconds": If you get connection errors: -1. Verify your `MONGODB_URI` is correct -2. Check that your IP address is whitelisted in Atlas -3. Verify your database user credentials are correct +1. Verify Docker is running (`docker ps`) +2. If using an external Atlas cluster, verify your `MONGODB_URI` is correct +3. Check that your IP address is whitelisted in Atlas (for external clusters) ## Notes 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 8c0db87..6aa4f34 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 @@ -270,8 +270,8 @@ void testCreateMoviesBatch_Success() { // Assert assertNotNull(result); - assertEquals(2, result.getInsertedCount()); - assertNotNull(result.getInsertedIds()); + assertEquals(2, result.insertedCount()); + assertNotNull(result.insertedIds()); verify(movieRepository).saveAll(anyList()); } @@ -364,7 +364,7 @@ void testDeleteMovie_Success() { // Assert assertNotNull(result); - assertEquals(1L, result.getDeletedCount()); + assertEquals(1L, result.deletedCount()); verify(movieRepository).existsById(testId); verify(movieRepository).deleteById(testId); } @@ -474,11 +474,11 @@ void testGetMoviesWithMostRecentComments_Success() { // Assert assertNotNull(results); assertEquals(1, results.size()); - assertEquals("Test Movie", results.get(0).getTitle()); - assertEquals(2024, results.get(0).getYear()); - assertEquals(5, results.get(0).getTotalComments()); - assertNotNull(results.get(0).getRecentComments()); - assertEquals(1, results.get(0).getRecentComments().size()); + assertEquals("Test Movie", results.get(0).title()); + assertEquals(2024, results.get(0).year()); + assertEquals(5, results.get(0).totalComments()); + assertNotNull(results.get(0).recentComments()); + assertEquals(1, results.get(0).recentComments().size()); verify(mongoTemplate).aggregate(any(Aggregation.class), eq("movies"), eq(Document.class)); } @@ -523,7 +523,7 @@ void testGetMoviesByYearWithStats_Success() { MoviesByYearResult result1 = MoviesByYearResult.builder() .year(2024) .movieCount(10) - .averageRating(7.5) + .averageRating(7.501) .highestRating(9.0) .lowestRating(6.0) .totalVotes(5000L) @@ -550,10 +550,10 @@ void testGetMoviesByYearWithStats_Success() { // Assert assertNotNull(results); assertEquals(2, results.size()); - assertEquals(2024, results.get(0).getYear()); - assertEquals(10, results.get(0).getMovieCount()); - assertEquals(7.5, results.get(0).getAverageRating()); - assertEquals(2023, results.get(1).getYear()); + assertEquals(2024, results.get(0).year()); + assertEquals(10, results.get(0).movieCount()); + assertEquals(7.5, results.get(0).averageRating()); + assertEquals(2023, results.get(1).year()); verify(mongoTemplate).aggregate(any(Aggregation.class), eq("movies"), eq(MoviesByYearResult.class)); } @@ -587,10 +587,10 @@ void testGetDirectorsWithMostMovies_Success() { // Assert assertNotNull(results); assertEquals(2, results.size()); - assertEquals("Christopher Nolan", results.get(0).getDirector()); - assertEquals(10, results.get(0).getMovieCount()); - assertEquals(8.5, results.get(0).getAverageRating()); - assertEquals("Steven Spielberg", results.get(1).getDirector()); + assertEquals("Christopher Nolan", results.get(0).director()); + assertEquals(10, results.get(0).movieCount()); + assertEquals(8.5, results.get(0).averageRating()); + assertEquals("Steven Spielberg", results.get(1).director()); verify(mongoTemplate).aggregate(any(Aggregation.class), eq("movies"), eq(DirectorStatisticsResult.class)); } @@ -634,8 +634,8 @@ void testUpdateMoviesBatch_Success() { // Assert assertNotNull(result); - assertEquals(5L, result.getMatchedCount()); - assertEquals(5L, result.getModifiedCount()); + assertEquals(5L, result.matchedCount()); + assertEquals(5L, result.modifiedCount()); verify(mongoTemplate).updateMulti(any(Query.class), any(org.springframework.data.mongodb.core.query.Update.class), eq(Movie.class)); } @@ -679,7 +679,7 @@ void testDeleteMoviesBatch_Success() { // Assert assertNotNull(result); - assertEquals(10L, result.getDeletedCount()); + assertEquals(10L, result.deletedCount()); verify(mongoTemplate).remove(any(Query.class), eq(Movie.class)); } diff --git a/mflix/server/java-spring/src/test/resources/application-test.properties b/mflix/server/java-spring/src/test/resources/application-test.properties index bb3103e..6360d0f 100644 --- a/mflix/server/java-spring/src/test/resources/application-test.properties +++ b/mflix/server/java-spring/src/test/resources/application-test.properties @@ -3,7 +3,7 @@ # MongoDB Configuration # For MongoDB Search tests, use the same MONGODB_URI as the main application -spring.data.mongodb.uri=${MONGODB_URI} +spring.data.mongodb.uri=${MONGODB_URI:} spring.data.mongodb.database=sample_mflix # Server Configuration diff --git a/mflix/server/js-express/.env.example b/mflix/server/js-express/.env.example index 8b264f6..c8ac03d 100644 --- a/mflix/server/js-express/.env.example +++ b/mflix/server/js-express/.env.example @@ -1,6 +1,6 @@ # MongoDB Connection # Replace with your MongoDB Atlas connection string or local MongoDB URI -MONGODB_URI=mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority" # OPTIONAL: Voyage AI Configuration (required for Vector Search) # Get your API key from https://www.voyageai.com/ diff --git a/mflix/server/python-fastapi/.env.example b/mflix/server/python-fastapi/.env.example index f0d77b2..4dc3fe8 100644 --- a/mflix/server/python-fastapi/.env.example +++ b/mflix/server/python-fastapi/.env.example @@ -1,6 +1,6 @@ # MongoDB Connection # Replace with your MongoDB Atlas connection string or local MongoDB URI -MONGODB_URI=mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority +MONGODB_URI="mongodb+srv://:@.mongodb.net/sample_mflix?retryWrites=true&w=majority" # OPTIONAL: Voyage AI Configuration (required for Vector Search) # Get your API key from https://www.voyageai.com/ diff --git a/mflix/server/python-fastapi/requirements.in b/mflix/server/python-fastapi/requirements.in index d46b4d9..d33eb2b 100644 --- a/mflix/server/python-fastapi/requirements.in +++ b/mflix/server/python-fastapi/requirements.in @@ -62,7 +62,7 @@ rich-toolkit~=0.15.1 # Extensions for the 'rich' library # Minimum versions for indirect dependencies. # ------------------------------------------------------------------------------ filelock>=3.20.3 # Transitive dep via huggingface-hub -aiohttp>=3.13.3 # Transitive dep via voyageai +aiohttp>=3.13.4 # Transitive dep via voyageai (CVE-2026-34525) orjson>=3.11.7 # Transitive dep via langsmith (CVE fix) langchain-core>=1.2.11 # Transitive dep via langchain-text-splitters (CVE-2026-26013 fix) pillow>=12.1.1 # Transitive dep via voyageai (CVE-2026-25990 fix) diff --git a/mflix/server/python-fastapi/requirements.txt b/mflix/server/python-fastapi/requirements.txt index 585daa3..a34cb54 100644 --- a/mflix/server/python-fastapi/requirements.txt +++ b/mflix/server/python-fastapi/requirements.txt @@ -6,7 +6,7 @@ # aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.13.3 +aiohttp==3.13.5 # via # -r requirements.in # voyageai