Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/run-express-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,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
Expand Down
6 changes: 5 additions & 1 deletion mflix/README-JAVA-SPRING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **2015**. Searching for movies from 2016 or later will return no results. This is a limitation of the sample dataset, not the application.
Comment thread
cbullinger marked this conversation as resolved.
Outdated

## 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/)
Expand Down
4 changes: 4 additions & 0 deletions mflix/README-JAVASCRIPT-EXPRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **2015**. Searching for movies from 2016 or later will return no results. This is a limitation of the sample dataset, not the application.
Comment thread
cbullinger marked this conversation as resolved.
Outdated

## Prerequisites

- **Node.js 22** or higher
Expand Down
4 changes: 4 additions & 0 deletions mflix/README-PYTHON-FASTAPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 **2015**. Searching for movies from 2016 or later will return no results. This is a limitation of the sample dataset, not the application.
Comment thread
cbullinger marked this conversation as resolved.
Outdated

## Prerequisites

- **Python 3.10** to **Python 3.13**
Expand Down
15 changes: 15 additions & 0 deletions mflix/client/app/components/FilterBar/FilterBar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
44 changes: 39 additions & 5 deletions mflix/client/app/components/FilterBar/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -39,6 +39,8 @@ export default function FilterBar({
const [filters, setFilters] = useState<MovieFilterParams>(initialFilters);
const [genres, setGenres] = useState<string[]>([]);
const [isLoadingGenres, setIsLoadingGenres] = useState(true);
const [maxDatasetYear, setMaxDatasetYear] = useState<number | null>(null);
const [minDatasetYear, setMinDatasetYear] = useState<number | null>(null);

// Track previous initialFilters to detect changes
const prevInitialFiltersRef = useRef<MovieFilterParams>(initialFilters);
Expand All @@ -54,6 +56,28 @@ export default function FilterBar({
loadGenres();
}, []);

// Fetch year bounds from the API on mount
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

Love this approach - seems much cleaner.

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)) {
Expand Down Expand Up @@ -134,16 +158,26 @@ export default function FilterBar({
</div>

<div className={styles.filterGroup}>
{maxDatasetYear && filters.year && filters.year > maxDatasetYear && (
<span className={styles.yearWarning}>
Dataset only contains movies up to {maxDatasetYear}
</span>
)}
{minDatasetYear && filters.year && filters.year < minDatasetYear && (
<span className={styles.yearWarning}>
Dataset only contains movies from {minDatasetYear} onwards
</span>
)}
<label className={styles.filterLabel}>Year</label>
<input
type="number"
className={styles.filterInput}
placeholder="e.g. 2020"
className={`${styles.filterInput} ${(maxDatasetYear && filters.year && filters.year > 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}
/>
</div>

Expand Down
13 changes: 11 additions & 2 deletions mflix/client/app/components/MovieCard/MovieCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,9 +57,9 @@ export default function MovieCard({ movie, isSelected = false, onSelectionChange
)}

<div className={movieStyles.moviePoster}>
{movie.poster ? (
{isValidPosterUrl(movie.poster) ? (
<Image
src={movie.poster}
src={movie.poster!}
alt={`${movie.title} poster`}
fill
sizes="(max-width: 480px) 100vw, (max-width: 768px) 50vw, 280px"
Expand Down
21 changes: 19 additions & 2 deletions mflix/client/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -585,13 +585,30 @@ export async function fetchMoviesByYear(): Promise<{ success: boolean; error?: s
error: 'Request timed out after 15 seconds'
};
}
return {
success: false,
return {
success: false,
error: error instanceof Error ? error.message : 'Network error occurred while fetching movies by year'
};
}
}

/**
* Fetch the min and max year bounds from the available movie data.
* This allows dynamic detection of the dataset's year range.
*/
export async function fetchYearBounds(): Promise<{ success: boolean; minYear?: number; maxYear?: number; error?: string }> {
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
*/
Expand Down
11 changes: 10 additions & 1 deletion mflix/client/app/movie/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -202,7 +211,7 @@ export default function MovieDetailsPage({ params }: MovieDetailsPageProps) {
) : (
<div className={pageStyles.movieDetails}>
<div className={pageStyles.posterSection}>
{movie.poster ? (
{isValidPosterUrl(movie.poster) ? (
<div className={pageStyles.posterContainer}>
<Image
src={movie.poster!}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public ResponseEntity<SuccessResponse<List<Movie>>> 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)
Expand All @@ -97,16 +97,18 @@ public ResponseEntity<SuccessResponse<List<Movie>>> getAllMovies(
.sortBy(sortBy)
.sortOrder(sortOrder)
.build();

List<Movie> movies = movieService.getAllMovies(query);


String message = "Found " + movies.size() + " movies";

SuccessResponse<List<Movie>> response = SuccessResponse.<List<Movie>>builder()
.success(true)
.message("Found " + movies.size() + " movies")
.message(message)
.data(movies)
.timestamp(Instant.now().toString())
.build();

return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ public List<MovieWithCommentsResult> 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()) {
Expand Down Expand Up @@ -461,7 +461,7 @@ public List<MoviesByYearResult> 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
Expand Down Expand Up @@ -512,7 +512,7 @@ public List<DirectorStatisticsResult> 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
Expand Down
6 changes: 3 additions & 3 deletions mflix/server/js-express/src/controllers/movieController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,7 @@ export async function getMoviesWithMostRecentComments(
// the collection
{
$match: {
year: { $type: "number", $gte: 1800, $lte: 2030 },
year: { $type: "number" },
},
},
];
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -195,14 +195,15 @@ 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(
testMovies,
"Found 1 movies"
);
});

});

describe("getMovieById", () => {
Expand Down
Loading
Loading