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 5ca9ceb..d696c3e 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 @@ -109,7 +109,26 @@ public ResponseEntity>> getAllMovies( return ResponseEntity.ok(response); } - + + @Operation( + summary = "Get all distinct genres", + description = "Retrieve a list of all unique genre values from the movies collection. " + + "Demonstrates the distinct() operation. Returns genres sorted alphabetically." + ) + @GetMapping("/genres") + 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); + } + @Operation( summary = "Get a single movie by ID", description = "Retrieve a single movie by its MongoDB ObjectId." diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java index 8221b27..4a592cb 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java @@ -21,6 +21,14 @@ public interface MovieService { List getAllMovies(MovieSearchQuery query); + /** + * Gets all distinct genre values from the movies collection. + * Demonstrates the distinct() operation. + * + * @return List of unique genre strings, sorted alphabetically + */ + List getDistinctGenres(); + Movie getMovieById(String id); Movie createMovie(CreateMovieRequest request); 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 138444f..d293b23 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 @@ -85,7 +85,25 @@ public List getAllMovies(MovieSearchQuery query) { return mongoTemplate.find(mongoQuery, Movie.class); } - + + @Override + public List getDistinctGenres() { + // Use MongoTemplate's findDistinct to get all unique values from the genres array field + // MongoDB automatically flattens array fields when using distinct() + List genres = mongoTemplate.findDistinct( + new Query(), + Movie.Fields.GENRES, + Movie.class, + String.class + ); + + // Filter out null/empty values and sort alphabetically + return genres.stream() + .filter(genre -> genre != null && !genre.isEmpty()) + .sorted(String::compareTo) + .collect(Collectors.toList()); + } + @Override public Movie getMovieById(String id) { if (!ObjectId.isValid(id)) { 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 0f63dfd..1add6ed 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 @@ -936,4 +936,38 @@ void testDeleteMoviesBatch_EmptyFilter() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.deletedCount").value(0)); } + + // ==================== GET DISTINCT GENRES TESTS ==================== + + @Test + @DisplayName("GET /api/movies/genres - Should return list of distinct genres") + void testGetDistinctGenres_Success() throws Exception { + // Arrange + List genres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi"); + when(movieService.getDistinctGenres()).thenReturn(genres); + + // Act & Assert + mockMvc.perform(get("/api/movies/genres")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(5))) + .andExpect(jsonPath("$.data[0]").value("Action")) + .andExpect(jsonPath("$.data[1]").value("Comedy")) + .andExpect(jsonPath("$.data[2]").value("Drama")); + } + + @Test + @DisplayName("GET /api/movies/genres - Should return empty list when no genres exist") + void testGetDistinctGenres_EmptyList() throws Exception { + // Arrange + when(movieService.getDistinctGenres()).thenReturn(Arrays.asList()); + + // Act & Assert + mockMvc.perform(get("/api/movies/genres")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(0))); + } } 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 335e763..8c0db87 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 @@ -801,4 +801,82 @@ void testFindSimilarMovies_MovieNotFound() { // Act & Assert assertThrows(ResourceNotFoundException.class, () -> movieService.findSimilarMovies(movieId, 10)); } + + // ==================== GET DISTINCT GENRES TESTS ==================== + + @Test + @DisplayName("Should get distinct genres successfully") + void testGetDistinctGenres_Success() { + // Arrange + List expectedGenres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi"); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(expectedGenres); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(5, result.size()); + assertEquals("Action", result.get(0)); + assertEquals("Comedy", result.get(1)); + verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)); + } + + @Test + @DisplayName("Should return empty list when no genres exist") + void testGetDistinctGenres_EmptyList() { + // Arrange + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(Arrays.asList()); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(0, result.size()); + verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)); + } + + @Test + @DisplayName("Should filter out null and empty genres") + void testGetDistinctGenres_FiltersNullAndEmpty() { + // Arrange + List genresWithNulls = new ArrayList<>(Arrays.asList("Action", null, "", "Drama", "Comedy")); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(genresWithNulls); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + // The service should filter out null and empty values + assertEquals(3, result.size()); + assertTrue(result.contains("Action")); + assertTrue(result.contains("Drama")); + assertTrue(result.contains("Comedy")); + assertFalse(result.contains(null)); + assertFalse(result.contains("")); + } + + @Test + @DisplayName("Should return genres sorted alphabetically") + void testGetDistinctGenres_SortedAlphabetically() { + // Arrange + List unsortedGenres = Arrays.asList("Drama", "Action", "Comedy"); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(unsortedGenres); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("Action", result.get(0)); + assertEquals("Comedy", result.get(1)); + assertEquals("Drama", result.get(2)); + } } diff --git a/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts b/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts index 71702e2..65e4553 100644 --- a/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts +++ b/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts @@ -455,6 +455,53 @@ describeSearch("MongoDB Search Integration Tests", () => { expect(response.body.error).toBeDefined(); }); }); + + describe("GET /api/movies/genres", () => { + test("should return list of distinct genres", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + + // Verify genres are strings + response.body.data.forEach((genre: any) => { + expect(typeof genre).toBe("string"); + expect(genre.length).toBeGreaterThan(0); + }); + }); + + test("should return genres sorted alphabetically", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + const genres = response.body.data; + + // Verify alphabetical sorting + for (let i = 0; i < genres.length - 1; i++) { + expect(genres[i].localeCompare(genres[i + 1])).toBeLessThanOrEqual(0); + } + }); + + test("should include common genres like Action, Drama, Comedy", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + const genres = response.body.data; + + // The sample_mflix dataset should contain these common genres + expect(genres).toContain("Action"); + expect(genres).toContain("Drama"); + expect(genres).toContain("Comedy"); + }); + }); }); diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 32f04a3..bb30c32 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -32,6 +32,10 @@ Search movies using MongoDB Vector Search to enable semantic search capabilities over the plot field. +- GET /api/movies/genres : + Retrieve all distinct genre values from the movies collection. + Demonstrates the distinct() operation. + - GET /api/movies/{id} : Retrieve a single movie by its ID. @@ -427,6 +431,41 @@ async def vector_search_movies( detail=f"Error performing vector search: {str(e)}" ) +""" + GET /api/movies/genres + + Retrieve all distinct genre values from the movies collection. + Demonstrates the distinct() operation. + + Returns: + SuccessResponse[List[str]]: A response object containing the list of unique genres, sorted alphabetically. +""" + +@router.get("/genres", + response_model=SuccessResponse[List[str]], + status_code=200, + summary="Retrieve all distinct genres from the movies collection.") +async def get_distinct_genres(): + movies_collection = get_collection("movies") + + try: + # Use distinct() to get all unique values from the genres array field + # MongoDB automatically flattens array fields when using distinct() + genres = await movies_collection.distinct("genres") + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Database error occurred: {str(e)}" + ) + + # Filter out null/empty values and sort alphabetically + valid_genres = sorted([ + genre for genre in genres + if isinstance(genre, str) and len(genre) > 0 + ]) + + return create_success_response(valid_genres, f"Found {len(valid_genres)} distinct genres") + """ GET /api/movies/{id} Retrieve a single movie by its ID. diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index 5256fab..5d74779 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -1091,3 +1091,82 @@ async def test_aggregate_directors_empty_results(self, mock_execute_aggregation) # Assertions assert result.success is True assert len(result.data) == 0 + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestGetDistinctGenres: + """Tests for GET /api/movies/genres endpoint.""" + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_success(self, mock_get_collection): + """Should return list of distinct genres sorted alphabetically.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = ["Drama", "Action", "Comedy", "Horror", "Sci-Fi"] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 5 + # Verify alphabetical sorting + assert result.data == ["Action", "Comedy", "Drama", "Horror", "Sci-Fi"] + mock_collection.distinct.assert_called_once_with("genres") + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_empty_list(self, mock_get_collection): + """Should return empty list when no genres exist.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = [] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 0 + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_filters_null_and_empty(self, mock_get_collection): + """Should filter out null and empty genre values.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = ["Action", None, "", "Drama", "Comedy"] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 3 + assert "Action" in result.data + assert "Drama" in result.data + assert "Comedy" in result.data + assert None not in result.data + assert "" not in result.data + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_database_error(self, mock_get_collection): + """Should handle database errors gracefully.""" + # Setup mock to raise exception + mock_collection = AsyncMock() + mock_collection.distinct.side_effect = Exception("Database connection failed") + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + with pytest.raises(HTTPException) as exc_info: + await get_distinct_genres() + + # Assertions + assert exc_info.value.status_code == 500 + assert "Database error" in str(exc_info.value.detail)