diff --git a/.github/scripts/generate-test-summary-jest.sh b/.github/scripts/generate-test-summary-jest.sh index 48e86bf..cb40ff7 100644 --- a/.github/scripts/generate-test-summary-jest.sh +++ b/.github/scripts/generate-test-summary-jest.sh @@ -5,11 +5,17 @@ set -e # Shows breakdown by test type (unit vs integration) # Usage: ./generate-test-summary-jest.sh +# Guard: skip if GITHUB_STEP_SUMMARY is not set +if [ -z "$GITHUB_STEP_SUMMARY" ]; then + echo "Warning: GITHUB_STEP_SUMMARY not set, skipping summary generation" + exit 0 +fi + UNIT_JSON="${1:-}" INTEGRATION_JSON="${2:-}" -echo "## Test Results" >> $GITHUB_STEP_SUMMARY -echo "" >> $GITHUB_STEP_SUMMARY +echo "## Test Results" >> "$GITHUB_STEP_SUMMARY" +echo "" >> "$GITHUB_STEP_SUMMARY" # Function to parse Jest JSON file parse_json() { @@ -55,37 +61,37 @@ total_failed=$((unit_failed + int_failed)) total_skipped=$((unit_skipped + int_skipped)) # Display detailed breakdown -echo "### Summary by Test Type" >> $GITHUB_STEP_SUMMARY -echo "" >> $GITHUB_STEP_SUMMARY -echo "| Test Type | Passed | Failed | Skipped | Total |" >> $GITHUB_STEP_SUMMARY -echo "|-----------|--------|--------|---------|-------|" >> $GITHUB_STEP_SUMMARY +echo "### Summary by Test Type" >> "$GITHUB_STEP_SUMMARY" +echo "" >> "$GITHUB_STEP_SUMMARY" +echo "| Test Type | Passed | Failed | Skipped | Total |" >> "$GITHUB_STEP_SUMMARY" +echo "|-----------|--------|--------|---------|-------|" >> "$GITHUB_STEP_SUMMARY" if [ -f "$UNIT_JSON" ]; then - echo "| 🔧 Unit Tests | $unit_passed | $unit_failed | $unit_skipped | $unit_tests |" >> $GITHUB_STEP_SUMMARY + echo "| 🔧 Unit Tests | $unit_passed | $unit_failed | $unit_skipped | $unit_tests |" >> "$GITHUB_STEP_SUMMARY" fi if [ -f "$INTEGRATION_JSON" ]; then - echo "| 🔗 Integration Tests | $int_passed | $int_failed | $int_skipped | $int_tests |" >> $GITHUB_STEP_SUMMARY + echo "| 🔗 Integration Tests | $int_passed | $int_failed | $int_skipped | $int_tests |" >> "$GITHUB_STEP_SUMMARY" fi -echo "| **Total** | **$total_passed** | **$total_failed** | **$total_skipped** | **$total_tests** |" >> $GITHUB_STEP_SUMMARY -echo "" >> $GITHUB_STEP_SUMMARY +echo "| **Total** | **$total_passed** | **$total_failed** | **$total_skipped** | **$total_tests** |" >> "$GITHUB_STEP_SUMMARY" +echo "" >> "$GITHUB_STEP_SUMMARY" # Overall status -echo "### Overall Status" >> $GITHUB_STEP_SUMMARY -echo "" >> $GITHUB_STEP_SUMMARY -echo "| Status | Count |" >> $GITHUB_STEP_SUMMARY -echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY -echo "| ✅ Passed | $total_passed |" >> $GITHUB_STEP_SUMMARY -echo "| ❌ Failed | $total_failed |" >> $GITHUB_STEP_SUMMARY -echo "| ⏭️ Skipped | $total_skipped |" >> $GITHUB_STEP_SUMMARY -echo "| **Total** | **$total_tests** |" >> $GITHUB_STEP_SUMMARY -echo "" >> $GITHUB_STEP_SUMMARY +echo "### Overall Status" >> "$GITHUB_STEP_SUMMARY" +echo "" >> "$GITHUB_STEP_SUMMARY" +echo "| Status | Count |" >> "$GITHUB_STEP_SUMMARY" +echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY" +echo "| ✅ Passed | $total_passed |" >> "$GITHUB_STEP_SUMMARY" +echo "| ❌ Failed | $total_failed |" >> "$GITHUB_STEP_SUMMARY" +echo "| ⏭️ Skipped | $total_skipped |" >> "$GITHUB_STEP_SUMMARY" +echo "| **Total** | **$total_tests** |" >> "$GITHUB_STEP_SUMMARY" +echo "" >> "$GITHUB_STEP_SUMMARY" # List failed tests if any if [ $total_failed -gt 0 ]; then - echo "### ❌ Failed Tests" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "### ❌ Failed Tests" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" failed_tests_file=$(mktemp) @@ -107,16 +113,16 @@ if [ $total_failed -gt 0 ]; then if [ -s "$failed_tests_file" ]; then while IFS= read -r test; do - echo "- \`$test\`" >> $GITHUB_STEP_SUMMARY + echo "- \`$test\`" >> "$GITHUB_STEP_SUMMARY" done < "$failed_tests_file" else - echo "_Unable to parse individual test names_" >> $GITHUB_STEP_SUMMARY + echo "_Unable to parse individual test names_" >> "$GITHUB_STEP_SUMMARY" fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ **Tests failed!**" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "❌ **Tests failed!**" >> "$GITHUB_STEP_SUMMARY" rm -f "$failed_tests_file" exit 1 else - echo "✅ **All tests passed!**" >> $GITHUB_STEP_SUMMARY + echo "✅ **All tests passed!**" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/.github/workflows/run-express-tests.yml b/.github/workflows/run-express-tests.yml index d434da2..eb1b2aa 100644 --- a/.github/workflows/run-express-tests.yml +++ b/.github/workflows/run-express-tests.yml @@ -41,6 +41,16 @@ jobs: - name: Add sample data to database run: mongorestore --archive=sampledata.archive --port=27017 + - name: Install MongoDB Shell (mongosh) + run: | + curl https://downloads.mongodb.com/compass/mongosh-2.3.8-linux-x64.tgz -o mongosh.tgz + tar -xzf mongosh.tgz + sudo cp mongosh-2.3.8-linux-x64/bin/* /usr/local/bin/ + + - name: Create indexes for aggregation performance + run: | + mongosh "mongodb://localhost:27017/sample_mflix?directConnection=true" --eval "db.comments.createIndex({ movie_id: 1 })" + - name: Set up Node.js uses: actions/setup-node@v4 with: @@ -54,13 +64,13 @@ jobs: working-directory: mflix/server/js-express run: npm run test:unit -- --json --outputFile=test-results-unit.json || true env: - MONGODB_URI: mongodb://localhost:27017/sample_mflix + MONGODB_URI: mongodb://localhost:27017/sample_mflix?directConnection=true - name: Run integration tests working-directory: mflix/server/js-express run: npm run test:integration -- --json --outputFile=test-results-integration.json || true env: - MONGODB_URI: mongodb://localhost:27017/sample_mflix + MONGODB_URI: mongodb://localhost:27017/sample_mflix?directConnection=true ENABLE_SEARCH_TESTS: true # Note: Vector search tests will be skipped without VOYAGE_API_KEY # Run these tests locally with a valid API key diff --git a/mflix/README-JAVA-SPRING.md b/mflix/README-JAVA-SPRING.md index d73a717..245afc5 100644 --- a/mflix/README-JAVA-SPRING.md +++ b/mflix/README-JAVA-SPRING.md @@ -14,12 +14,16 @@ This is a full-stack movie browsing application built with Java Spring Boot and └── mvnw ``` +## Data Limitations + +The `sample_mflix` dataset contains movies released up to **2016**. Searching for movies from 2017 or later will return no results. This is a limitation of the sample dataset, not the application. + ## Prerequisites - **Java 21** or higher - **Node.js 20** or higher - **MongoDB Atlas cluster or local deployment** with the `sample_mflix` dataset loaded - - [Load sample data](https://www.mongodb.com/docs/atlas/sample-data/) + - [Load sample data](https://www.mongodb.com/docs/atlas/sample-data/) - **Maven** (included via Maven Wrapper) - **Voyage AI API key** (For MongoDB Vector Search) - [Get a Voyage AI API key](https://www.voyageai.com/) diff --git a/mflix/README-JAVASCRIPT-EXPRESS.md b/mflix/README-JAVASCRIPT-EXPRESS.md index 130bf5f..f3c531c 100644 --- a/mflix/README-JAVASCRIPT-EXPRESS.md +++ b/mflix/README-JAVASCRIPT-EXPRESS.md @@ -14,6 +14,10 @@ This is a full-stack movie browsing application built with Express.js and Next.j └── tsconfig.json ``` +## Data Limitations + +The `sample_mflix` dataset contains movies released up to **2016**. Searching for movies from 2017 or later will return no results. This is a limitation of the sample dataset, not the application. + ## Prerequisites - **Node.js 22** or higher diff --git a/mflix/README-PYTHON-FASTAPI.md b/mflix/README-PYTHON-FASTAPI.md index f3be9de..3d79f3b 100644 --- a/mflix/README-PYTHON-FASTAPI.md +++ b/mflix/README-PYTHON-FASTAPI.md @@ -17,6 +17,10 @@ This is a full-stack movie browsing application built with Python FastAPI and Ne └── requirements.txt ``` +## Data Limitations + +The `sample_mflix` dataset contains movies released up to **2016**. Searching for movies from 2017 or later will return no results. This is a limitation of the sample dataset, not the application. + ## Prerequisites - **Python 3.10** to **Python 3.13** diff --git a/mflix/client/app/components/FilterBar/FilterBar.module.css b/mflix/client/app/components/FilterBar/FilterBar.module.css index ee17f25..990b3a9 100644 --- a/mflix/client/app/components/FilterBar/FilterBar.module.css +++ b/mflix/client/app/components/FilterBar/FilterBar.module.css @@ -93,6 +93,21 @@ box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } +.inputWarning { + border-color: #f59e0b; +} + +.inputWarning:focus { + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.15); +} + +.yearWarning { + font-size: 0.7rem; + color: #b45309; + margin-top: 0.25rem; +} + .ratingGroup { display: flex; align-items: center; diff --git a/mflix/client/app/components/FilterBar/FilterBar.tsx b/mflix/client/app/components/FilterBar/FilterBar.tsx index 324840d..8f6b45d 100644 --- a/mflix/client/app/components/FilterBar/FilterBar.tsx +++ b/mflix/client/app/components/FilterBar/FilterBar.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import styles from './FilterBar.module.css'; -import { fetchGenres, type MovieFilterParams } from '@/lib/api'; +import { fetchGenres, fetchYearBounds, type MovieFilterParams } from '@/lib/api'; const SORT_OPTIONS = [ { value: 'title', label: 'Title' }, @@ -39,6 +39,8 @@ export default function FilterBar({ const [filters, setFilters] = useState(initialFilters); const [genres, setGenres] = useState([]); const [isLoadingGenres, setIsLoadingGenres] = useState(true); + const [maxDatasetYear, setMaxDatasetYear] = useState(null); + const [minDatasetYear, setMinDatasetYear] = useState(null); // Track previous initialFilters to detect changes const prevInitialFiltersRef = useRef(initialFilters); @@ -54,6 +56,28 @@ export default function FilterBar({ loadGenres(); }, []); + // Fetch year bounds from the API on mount + useEffect(() => { + async function loadYearBounds() { + console.log('FilterBar: Fetching year bounds...'); + const result = await fetchYearBounds(); + console.log('FilterBar: Year bounds result:', result); + if (result.success) { + if (result.maxYear) { + console.log('FilterBar: Setting maxDatasetYear to', result.maxYear); + setMaxDatasetYear(result.maxYear); + } + if (result.minYear) { + console.log('FilterBar: Setting minDatasetYear to', result.minYear); + setMinDatasetYear(result.minYear); + } + } else { + console.warn('FilterBar: Failed to fetch year bounds:', result.error); + } + } + loadYearBounds(); + }, []); + // Sync internal state when initialFilters changes (e.g. from URL navigation) useEffect(() => { if (!areFiltersEqual(prevInitialFiltersRef.current, initialFilters)) { @@ -134,16 +158,26 @@ export default function FilterBar({
+ {maxDatasetYear && filters.year && filters.year > maxDatasetYear && ( + + Dataset only contains movies up to {maxDatasetYear} + + )} + {minDatasetYear && filters.year && filters.year < minDatasetYear && ( + + Dataset only contains movies from {minDatasetYear} onwards + + )} maxDatasetYear) || (minDatasetYear && filters.year && filters.year < minDatasetYear) ? styles.inputWarning : ''}`} + placeholder="e.g. 2010" value={filters.year || ''} onChange={(e) => handleFilterChange('year', e.target.value ? parseInt(e.target.value) : undefined)} disabled={isLoading} - min={1900} - max={2030} + min={minDatasetYear || undefined} + max={maxDatasetYear || undefined} />
diff --git a/mflix/client/app/components/MovieCard/MovieCard.tsx b/mflix/client/app/components/MovieCard/MovieCard.tsx index 7fa702c..04afb91 100644 --- a/mflix/client/app/components/MovieCard/MovieCard.tsx +++ b/mflix/client/app/components/MovieCard/MovieCard.tsx @@ -14,6 +14,15 @@ import React from "react"; * such as image error handling and selection checkbox. */ +/** + * Validates that a poster URL is valid for Next.js Image component. + * Must be an absolute URL (http/https) or a relative path starting with / + */ +const isValidPosterUrl = (url: string | undefined): boolean => { + if (!url || typeof url !== 'string') return false; + return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'); +}; + interface MovieCardProps { movie: Movie; isSelected?: boolean; @@ -48,9 +57,9 @@ export default function MovieCard({ movie, isSelected = false, onSelectionChange )}
- {movie.poster ? ( + {isValidPosterUrl(movie.poster) ? ( {`${movie.title} { + const result = await fetchMoviesByYear(); + if (!result.success || !result.data || result.data.length === 0) { + return { success: false, error: result.error || 'No year data available' }; + } + const years = result.data.map(stat => stat.year); + return { + success: true, + minYear: Math.min(...years), + maxYear: Math.max(...years) + }; +} + /** * Fetch directors with most movies and their statistics */ diff --git a/mflix/client/app/movie/[id]/page.tsx b/mflix/client/app/movie/[id]/page.tsx index 86395c4..ef6a612 100644 --- a/mflix/client/app/movie/[id]/page.tsx +++ b/mflix/client/app/movie/[id]/page.tsx @@ -11,6 +11,15 @@ import { Movie } from '@/types/movie'; import { ROUTES } from '@/lib/constants'; import pageStyles from './page.module.css'; +/** + * Validates that a poster URL is valid for Next.js Image component. + * Must be an absolute URL (http/https) or a relative path starting with / + */ +const isValidPosterUrl = (url: string | undefined): boolean => { + if (!url || typeof url !== 'string') return false; + return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('/'); +}; + interface MovieDetailsPageProps { params: Promise<{ id: string; @@ -202,7 +211,7 @@ export default function MovieDetailsPage({ params }: MovieDetailsPageProps) { ) : (
- {movie.poster ? ( + {isValidPosterUrl(movie.poster) ? (
>> getAllMovies( @RequestParam(defaultValue = "title") String sortBy, @Parameter(description = "Sort order: 'asc' or 'desc' (default: asc)") @RequestParam(defaultValue = "asc") String sortOrder) { - + MovieSearchQuery query = MovieSearchQuery.builder() .q(q) .genre(genre) @@ -97,16 +97,18 @@ public ResponseEntity>> getAllMovies( .sortBy(sortBy) .sortOrder(sortOrder) .build(); - + List movies = movieService.getAllMovies(query); - + + String message = "Found " + movies.size() + " movies"; + SuccessResponse> response = SuccessResponse.>builder() .success(true) - .message("Found " + movies.size() + " movies") + .message(message) .data(movies) .timestamp(Instant.now().toString()) .build(); - + return ResponseEntity.ok(response); } 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 d293b23..3ccb9d5 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 @@ -376,7 +376,7 @@ public List getMoviesWithMostRecentComments(Integer lim int resultLimit = Math.clamp(limit != null ? limit : 10, 1, 50); // Build match criteria - Criteria matchCriteria = Criteria.where(Movie.Fields.YEAR).type(16).gte(1800).lte(2030); + Criteria matchCriteria = Criteria.where(Movie.Fields.YEAR).type(16); // Add movie ID filter if provided if (movieId != null && !movieId.trim().isEmpty()) { @@ -461,7 +461,7 @@ public List getMoviesByYearWithStats() { Aggregation aggregation = Aggregation.newAggregation( // STAGE 1: Match movies with valid year data Aggregation.match( - Criteria.where(Movie.Fields.YEAR).type(16).gte(1800).lte(2030) + Criteria.where(Movie.Fields.YEAR).type(16) ), // STAGE 2: Group by year and calculate statistics @@ -512,7 +512,7 @@ public List getDirectorsWithMostMovies(Integer limit) // STAGE 1: Match movies with directors and valid year Aggregation.match( Criteria.where(Movie.Fields.DIRECTORS).exists(true).ne(null).ne(List.of()) - .and(Movie.Fields.YEAR).type(16).gte(1800).lte(2030) + .and(Movie.Fields.YEAR).type(16) ), // STAGE 2: Unwind directors array diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index 78437ea..26801e4 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -967,7 +967,7 @@ export async function getMoviesWithMostRecentComments( // the collection { $match: { - year: { $type: "number", $gte: 1800, $lte: 2030 }, + year: { $type: "number" }, }, }, ]; @@ -1105,7 +1105,7 @@ export async function getMoviesByYearWithStats( // STAGE 1: Data quality filter { $match: { - year: { $type: "number", $gte: 1800, $lte: 2030 }, + year: { $type: "number" }, }, }, // STAGE 2: Group by year and calculate statistics @@ -1209,7 +1209,7 @@ export async function getDirectorsWithMostMovies( { $match: { directors: { $exists: true, $ne: null, $not: { $eq: [] } }, - year: { $type: "number", $gte: 1800, $lte: 2030 }, + year: { $type: "number" }, }, }, // STAGE 2: Unwind directors array diff --git a/mflix/server/js-express/tests/controllers/movieController.test.ts b/mflix/server/js-express/tests/controllers/movieController.test.ts index f6d5b2e..433994b 100644 --- a/mflix/server/js-express/tests/controllers/movieController.test.ts +++ b/mflix/server/js-express/tests/controllers/movieController.test.ts @@ -183,7 +183,7 @@ describe("Movie Controller Tests", () => { const testMovies = [{ _id: TEST_MOVIE_ID, title: "Action Movie" }]; mockRequest.query = { genre: "Action", - year: "2024", + year: "2010", minRating: "7.0", limit: "10", sortBy: "year", @@ -195,7 +195,7 @@ describe("Movie Controller Tests", () => { expect(mockFind).toHaveBeenCalledWith({ genres: { $regex: new RegExp("Action", "i") }, - year: 2024, + year: 2010, "imdb.rating": { $gte: 7.0 }, }); expect(mockCreateSuccessResponse).toHaveBeenCalledWith( @@ -203,6 +203,7 @@ describe("Movie Controller Tests", () => { "Found 1 movies" ); }); + }); describe("getMovieById", () => { diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index bb30c32..1da9b6a 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -554,7 +554,7 @@ async def get_all_movies( filter_dict["title"] = {"$regex": title, "$options": "i"} if genre: filter_dict["genres"] = {"$regex": genre, "$options": "i"} - if year: + if isinstance(year, int): filter_dict["year"] = year if min_rating is not None or max_rating is not None: rating_filter = {} @@ -593,8 +593,10 @@ async def get_all_movies( movie["year"] = None movies.append(movie) + # Return the results wrapped in a SuccessResponse - return create_success_response(movies, f"Found {len(movies)} movies.") + message = f"Found {len(movies)} movies." + return create_success_response(movies, message) """ POST /api/movies/ @@ -1014,7 +1016,7 @@ async def aggregate_movies_recent_commented( # Filter movies to only those with valid year data { "$match": { - "year": {"$type": "number", "$gte": 1800, "$lte": 2030} + "year": {"$type": "number"} } } ] @@ -1160,7 +1162,7 @@ async def aggregate_movies_by_year(): # Tip: Filter early to reduce dataset size and improve performance { "$match": { - "year": {"$type": "number", "$gte": 1800, "$lte": 2030} + "year": {"$type": "number"} } }, @@ -1289,7 +1291,7 @@ async def aggregate_directors_most_movies( { "$match": { "directors": {"$exists": True, "$ne": None, "$ne": []}, # Has directors array - "year": {"$type": "number", "$gte": 1800, "$lte": 2030} # Valid year range + "year": {"$type": "number"} # Valid year (numeric) } }, diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index 5d74779..8bb335d 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -394,7 +394,7 @@ async def test_get_all_movies_database_error(self, mock_get_collection): # Call the route handler from src.routers.movies import get_all_movies - with pytest.raises(HTTPException) as e: + with pytest.raises(HTTPException) as e: await get_all_movies() # Assertions