diff --git a/.github/scripts/generate-test-summary-jest.sh b/.github/scripts/generate-test-summary-jest.sh index 6bbdf35..48e86bf 100644 --- a/.github/scripts/generate-test-summary-jest.sh +++ b/.github/scripts/generate-test-summary-jest.sh @@ -1,83 +1,122 @@ #!/bin/bash set -e -# Generate Test Summary from Jest JSON Output -# Usage: ./generate-test-summary-jest.sh +# Generate Detailed Test Summary from Multiple Jest JSON Output Files +# Shows breakdown by test type (unit vs integration) +# Usage: ./generate-test-summary-jest.sh -JSON_FILE="${1:-test-results.json}" +UNIT_JSON="${1:-}" +INTEGRATION_JSON="${2:-}" echo "## Test Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY -# Parse test results from Jest JSON output -if [ -f "$JSON_FILE" ]; then - # Extract test counts using jq or grep/sed - # Jest JSON structure: { "numTotalTests": N, "numPassedTests": N, "numFailedTests": N, "numPendingTests": N, ... } - +# Function to parse Jest JSON file +parse_json() { + local json_file="$1" + local test_type="$2" + + if [ ! -f "$json_file" ]; then + echo "0 0 0 0" + return + fi + if command -v jq &> /dev/null; then # Use jq if available (preferred) - total_tests=$(jq -r '.numTotalTests // 0' "$JSON_FILE") - passed=$(jq -r '.numPassedTests // 0' "$JSON_FILE") - failed=$(jq -r '.numFailedTests // 0' "$JSON_FILE") - skipped=$(jq -r '.numPendingTests // 0' "$JSON_FILE") - - # Extract failed test details - failed_tests_file=$(mktemp) - jq -r '.testResults[]? | select(.status == "failed") | .assertionResults[]? | select(.status == "failed") | "\(.ancestorTitles | join(" > ")) > \(.title)"' "$JSON_FILE" > "$failed_tests_file" 2>/dev/null || true + total_tests=$(jq -r '.numTotalTests // 0' "$json_file") + passed=$(jq -r '.numPassedTests // 0' "$json_file") + failed=$(jq -r '.numFailedTests // 0' "$json_file") + skipped=$(jq -r '.numPendingTests // 0' "$json_file") else # Fallback to grep/sed if jq is not available - total_tests=$(grep -oP '"numTotalTests":\s*\K[0-9]+' "$JSON_FILE" | head -1) - passed=$(grep -oP '"numPassedTests":\s*\K[0-9]+' "$JSON_FILE" | head -1) - failed=$(grep -oP '"numFailedTests":\s*\K[0-9]+' "$JSON_FILE" | head -1) - skipped=$(grep -oP '"numPendingTests":\s*\K[0-9]+' "$JSON_FILE" | head -1) - - # Extract failed test names (basic extraction without jq) - failed_tests_file=$(mktemp) - grep -oP '"fullName":\s*"\K[^"]*' "$JSON_FILE" | while read -r line; do - if echo "$line" | grep -q "failed"; then - echo "$line" >> "$failed_tests_file" - fi - done 2>/dev/null || true + total_tests=$(grep -oP '"numTotalTests":\s*\K[0-9]+' "$json_file" | head -1) + passed=$(grep -oP '"numPassedTests":\s*\K[0-9]+' "$json_file" | head -1) + failed=$(grep -oP '"numFailedTests":\s*\K[0-9]+' "$json_file" | head -1) + skipped=$(grep -oP '"numPendingTests":\s*\K[0-9]+' "$json_file" | head -1) fi - + # Default to 0 if values are empty total_tests=${total_tests:-0} passed=${passed:-0} failed=${failed:-0} skipped=${skipped:-0} - echo "| Status | Count |" >> $GITHUB_STEP_SUMMARY - echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| ✅ Passed | $passed |" >> $GITHUB_STEP_SUMMARY - echo "| ❌ Failed | $failed |" >> $GITHUB_STEP_SUMMARY - echo "| ⏭️ Skipped | $skipped |" >> $GITHUB_STEP_SUMMARY - echo "| **Total** | **$total_tests** |" >> $GITHUB_STEP_SUMMARY + echo "$total_tests $passed $failed $skipped" +} + +# Parse both files +read -r unit_tests unit_passed unit_failed unit_skipped <<< "$(parse_json "$UNIT_JSON" "Unit")" +read -r int_tests int_passed int_failed int_skipped <<< "$(parse_json "$INTEGRATION_JSON" "Integration")" + +# Calculate totals +total_tests=$((unit_tests + int_tests)) +total_passed=$((unit_passed + int_passed)) +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 + +if [ -f "$UNIT_JSON" ]; then + 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 +fi + +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 + +# List failed tests if any +if [ $total_failed -gt 0 ]; then + echo "### ❌ Failed Tests" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - # List failed tests if any - if [ "$failed" -gt 0 ]; then - echo "### ❌ Failed Tests" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -s "$failed_tests_file" ]; then - while IFS= read -r test; do - echo "- \`$test\`" >> $GITHUB_STEP_SUMMARY - done < "$failed_tests_file" - else - echo "_Unable to parse individual test names_" >> $GITHUB_STEP_SUMMARY + failed_tests_file=$(mktemp) + + # Extract failed tests from both files + for json_file in "$UNIT_JSON" "$INTEGRATION_JSON"; do + if [ -f "$json_file" ]; then + if command -v jq &> /dev/null; then + jq -r '.testResults[]? | select(.status == "failed") | .assertionResults[]? | select(.status == "failed") | "\(.ancestorTitles | join(" > ")) > \(.title)"' "$json_file" >> "$failed_tests_file" 2>/dev/null || true + else + # Basic fallback without jq + grep -oP '"fullName":\s*"\K[^"]*' "$json_file" | while read -r line; do + if echo "$line" | grep -q "failed"; then + echo "$line" >> "$failed_tests_file" + fi + done 2>/dev/null || true + fi fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ **Tests failed!**" >> $GITHUB_STEP_SUMMARY - rm -f "$failed_tests_file" - exit 1 + done + + if [ -s "$failed_tests_file" ]; then + while IFS= read -r test; do + echo "- \`$test\`" >> $GITHUB_STEP_SUMMARY + done < "$failed_tests_file" else - echo "✅ **All tests passed!**" >> $GITHUB_STEP_SUMMARY + echo "_Unable to parse individual test names_" >> $GITHUB_STEP_SUMMARY fi - + + echo "" >> $GITHUB_STEP_SUMMARY + echo "❌ **Tests failed!**" >> $GITHUB_STEP_SUMMARY rm -f "$failed_tests_file" -else - echo "⚠️ No test results found at: $JSON_FILE" >> $GITHUB_STEP_SUMMARY exit 1 +else + 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 e13673e..b180844 100644 --- a/.github/workflows/run-express-tests.yml +++ b/.github/workflows/run-express-tests.yml @@ -4,9 +4,13 @@ on: pull_request_target: branches: - development + paths: + - 'server/express/**' push: branches: - development + paths: + - 'server/express/**' jobs: test: @@ -33,11 +37,18 @@ jobs: - name: Install dependencies run: npm install - - name: Run tests - run: npm test -- --json --outputFile=test-results.json || true + - name: Run unit tests + run: npm run test:unit -- --json --outputFile=test-results-unit.json || true env: MONGODB_URI: ${{ secrets.MFLIX_URI }} + - name: Run integration tests + run: npm run test:integration -- --json --outputFile=test-results-integration.json || true + env: + MONGODB_URI: ${{ secrets.MFLIX_URI }} + ENABLE_SEARCH_TESTS: true + VOYAGE_API_KEY: ${{ secrets.VOYAGE_AI }} + - name: Upload test results uses: actions/upload-artifact@v4 if: always() @@ -45,7 +56,8 @@ jobs: name: test-results path: | server/express/coverage/ - server/express/test-results.json + server/express/test-results-unit.json + server/express/test-results-integration.json retention-days: 30 - name: Generate Test Summary @@ -53,4 +65,6 @@ jobs: working-directory: . run: | chmod +x .github/scripts/generate-test-summary-jest.sh - .github/scripts/generate-test-summary-jest.sh server/express/test-results.json + .github/scripts/generate-test-summary-jest.sh \ + server/express/test-results-unit.json \ + server/express/test-results-integration.json diff --git a/server/express/.env.example b/server/express/.env.example index c3551b3..027a11e 100644 --- a/server/express/.env.example +++ b/server/express/.env.example @@ -7,4 +7,8 @@ PORT=3001 NODE_ENV=development # CORS Configuration (frontend URL) -CORS_ORIGIN=http://localhost:3000 \ No newline at end of file +CORS_ORIGIN=http://localhost:3000 + +# Optional: Enable MongoDB Search tests +# Uncomment the following line to enable Search tests +# ENABLE_SEARCH_TESTS=true \ No newline at end of file diff --git a/server/express/jest.config.json b/server/express/jest.config.json index 4c7a843..ec2b758 100644 --- a/server/express/jest.config.json +++ b/server/express/jest.config.json @@ -6,6 +6,10 @@ "**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts" ], + "testPathIgnorePatterns": [ + "/node_modules/", + "/tests/integration/" + ], "transform": { "^.+\\.ts$": "ts-jest" }, diff --git a/server/express/jest.integration.config.json b/server/express/jest.integration.config.json new file mode 100644 index 0000000..314eff4 --- /dev/null +++ b/server/express/jest.integration.config.json @@ -0,0 +1,15 @@ +{ + "preset": "ts-jest", + "testEnvironment": "node", + "roots": ["/tests/integration"], + "testMatch": [ + "**/integration/**/*.test.ts", + "**/integration/**/*.spec.ts" + ], + "transform": { + "^.+\\.ts$": "ts-jest" + }, + "setupFilesAfterEnv": ["/tests/integration/setup.ts"], + "testTimeout": 60000 +} + diff --git a/server/express/package.json b/server/express/package.json index b776f78..2035bac 100644 --- a/server/express/package.json +++ b/server/express/package.json @@ -14,6 +14,7 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:unit": "jest tests/controllers", + "test:integration": "jest --config jest.integration.config.json", "test:verbose": "jest --verbose", "test:silent": "jest --silent" }, @@ -30,9 +31,11 @@ "@types/express": "^4.17.21", "@types/jest": "^29.5.14", "@types/node": "^20.10.5", + "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "jest": "^29.7.0", + "supertest": "^7.1.4", "ts-jest": "^29.1.1", "ts-node": "^10.9.2", "typescript": "^5.3.3" diff --git a/server/express/src/app.ts b/server/express/src/app.ts index 1d4898f..9c5a7e8 100644 --- a/server/express/src/app.ts +++ b/server/express/src/app.ts @@ -161,5 +161,10 @@ process.on("SIGTERM", () => { process.exit(0); }); -// Start the server -startServer(); +// Export the app for testing +export { app }; + +// Only start the server if this file is run directly (not imported for testing) +if (require.main === module) { + startServer(); +} diff --git a/server/express/src/utils/errorHandler.ts b/server/express/src/utils/errorHandler.ts index 4ddd5c6..35d79a6 100644 --- a/server/express/src/utils/errorHandler.ts +++ b/server/express/src/utils/errorHandler.ts @@ -38,13 +38,16 @@ export function errorHandler( ): void { // Log the error for debugging purposes // In production, we recommend using a logging service - console.error("Error occurred:", { - message: err.message, - stack: err.stack, - url: req.url, - method: req.method, - timestamp: new Date().toISOString(), - }); + // Suppress error logging during tests to keep test output clean + if (process.env.NODE_ENV !== "test") { + console.error("Error occurred:", { + message: err.message, + stack: err.stack, + url: req.url, + method: req.method, + timestamp: new Date().toISOString(), + }); + } // Determine the appropriate HTTP status code and error message const errorDetails = parseErrorDetails(err); diff --git a/server/express/tests/integration/README.md b/server/express/tests/integration/README.md new file mode 100644 index 0000000..8fbd90f --- /dev/null +++ b/server/express/tests/integration/README.md @@ -0,0 +1,192 @@ +# Integration Tests + +This directory contains integration tests for the Express MongoDB application. Unlike unit tests, these tests make actual HTTP requests to the Express app using `supertest` and connect to a real MongoDB instance to verify end-to-end functionality. + +## Overview + +The integration tests are organized into three main categories: + +1. **Movie CRUD Integration Tests** (`movie.integration.test.ts`) + - Tests basic CRUD API endpoints using `supertest` + - Makes actual HTTP requests to the Express app + - Tests pagination, filtering, and sorting via API + - Automatically skipped if `MONGODB_URI` is not set + +2. **MongoDB Search Integration Tests** (`mongodbSearch.integration.test.ts`) + - Tests the MongoDB Atlas Search API endpoint using `supertest` + - Makes actual HTTP requests to test the `/api/movies/search` endpoint + - Tests search by plot, directors, cast, pagination, and search operators + - Automatically skipped if `ENABLE_SEARCH_TESTS` is not set + +3. **Advanced Endpoints Integration Tests** (`advancedEndpoints.integration.test.ts`) + - Tests advanced API endpoints using `supertest` + - Makes actual HTTP requests to test: + - **Aggregation endpoints**: Movies with comments, statistics by year, directors with most movies + - **Atlas Search endpoint**: Compound queries, phrase matching, fuzzy matching + - **Vector Search endpoint**: Semantic similarity search using embeddings + - Aggregation tests automatically run if `MONGODB_URI` is set + - Atlas Search tests require `ENABLE_SEARCH_TESTS=true` + - Vector Search tests require `VOYAGE_API_KEY` to be set + +**Note:** Tests use `describeIntegration`, `describeSearch`, and `describeVectorSearch` wrappers (from `setup.ts` and test files) that automatically skip entire test suites when the required environment variables are not set. + +## Testing Approach + +These integration tests use **supertest** to make actual HTTP requests to the Express application, testing the complete request/response cycle including: +- Routing +- Request parsing +- Controller logic +- Database operations +- Response formatting +- Error handling + +This approach ensures that the API endpoints work correctly from the client's perspective. + +## Requirements + +### Basic Integration Tests (CRUD and Aggregations) + +- **MONGODB_URI** environment variable must be set +- MongoDB instance must be accessible (can be local MongoDB or Atlas) + +### Atlas Search Tests + +- **MongoDB instance** with Search enabled (local MongoDB or Atlas) +- **MONGODB_URI** environment variable +- **ENABLE_SEARCH_TESTS=true** environment variable to enable tests + +### Vector Search Tests + +- **MONGODB_URI** environment variable must be set +- **VOYAGE_API_KEY** environment variable must be set with a valid Voyage AI API key +- MongoDB instance must have the `embedded_movies` collection with vector embeddings +- Vector search index must be configured on the `embedded_movies` collection + +## Running the Tests + +### Run All Integration Tests + +```bash +npm run test:integration +``` + +### Run Specific Test File + +```bash +# Run only CRUD tests +npx jest --config jest.integration.config.json tests/integration/movie.integration.test.ts + +# Run only Search tests (requires Search-enabled MongoDB) +npx jest --config jest.integration.config.json tests/integration/mongodbSearch.integration.test.ts + +# Run only Advanced Endpoints tests (aggregations, search, vector search) +npx jest --config jest.integration.config.json tests/integration/advancedEndpoints.integration.test.ts +``` + +### Set Environment Variables + +#### Using .env file + +Create a `.env` file in the `server/express` directory: + +```env +MONGODB_URI=mongodb://localhost:27017/sample_mflix +# or for Atlas +MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/sample_mflix?retryWrites=true&w=majority + +# Optional: Enable Search tests (requires Search-enabled MongoDB) +ENABLE_SEARCH_TESTS=true + +# Optional: Enable Vector Search tests (requires Voyage AI API key) +VOYAGE_API_KEY=your-voyage-ai-api-key +``` + +## Test Structure + +### Setup and Teardown + +All integration tests follow an **idempotent pattern** to ensure they can be run multiple times without side effects: + +- **Global Setup** (`beforeAll` in `setup.ts`): Connects to MongoDB once before all tests +- **Global Teardown** (`afterAll` in `setup.ts`): Closes MongoDB connection after all tests +- **Pre-Test Cleanup** (`beforeAll` in test files): Removes any orphaned test data from previous runs +- **Post-Test Cleanup** (`afterEach` in test files): Removes test data created during each test + +This ensures: +- Tests can be run multiple times without conflicts +- The dataset is left in an untouched state after test execution +- Tests are isolated from each other +- Failed test runs don't affect subsequent runs + +## Troubleshooting + +### Tests are Skipped + +If you see "⚠️ Skipping integration tests - MONGODB_URI not set": + +1. Make sure `MONGODB_URI` environment variable is set +2. Verify the connection string is correct +3. Check that MongoDB is running and accessible + +### Search Tests are Skipped + +If you see "⚠️ Skipping MongoDB Search tests - ENABLE_SEARCH_TESTS not set": + +1. Set `ENABLE_SEARCH_TESTS=true` environment variable +2. Verify your MongoDB instance has Search enabled (available in both local MongoDB and Atlas) +3. For local MongoDB, ensure you're running a version that supports Search + +### Vector Search Tests are Skipped + +If you see "⚠️ Vector Search tests skipped - VOYAGE_API_KEY not set": + +1. Set `VOYAGE_API_KEY` environment variable with your Voyage AI API key +2. Verify your MongoDB instance has the `embedded_movies` collection +3. Ensure the vector search index is configured on the `embedded_movies` collection +4. The `embedded_movies` collection should have documents with `plot_embedding_voyage_3_large` field + +### Connection Errors + +If tests fail with connection errors: + +1. Verify `MONGODB_URI` is correct +2. Check that MongoDB is running and accessible +3. For Atlas: Verify your IP address is whitelisted +4. For Atlas: Verify database user credentials are correct +5. For local MongoDB: Verify the connection string format is correct + +### Index Creation Timeout + +If search tests fail with "Search index did not become ready within 120 seconds": + +1. Check that your MongoDB instance has Search enabled +2. Verify the search index is being created (check logs or admin UI) +3. The index creation time varies by instance type and data size +4. For Atlas free tier clusters, index creation may take longer +5. For local MongoDB, ensure Search is properly configured + +### Test Timeouts + +Integration tests have a 2-minute timeout (120 seconds) to accommodate: + +- Network latency +- Index creation and polling +- Document indexing delays + +If tests timeout, you may need to: + +1. Check your network connection +2. Use a faster MongoDB instance +3. Increase the timeout in `jest.integration.config.json` + +## Differences from Unit Tests + +| Aspect | Unit Tests | Integration Tests | +|--------|-----------|-------------------| +| HTTP Requests | Mocked with Jest | Real HTTP requests via supertest | +| Database | Mocked with Jest | Real MongoDB instance | +| Speed | Fast (milliseconds) | Slower (seconds) | +| Dependencies | None | Requires MongoDB | +| Isolation | Complete | Requires cleanup | +| CI/CD | Always run | Conditional (requires MONGODB_URI) | +| Purpose | Test business logic | Test end-to-end API functionality | diff --git a/server/express/tests/integration/advancedEndpoints.integration.test.ts b/server/express/tests/integration/advancedEndpoints.integration.test.ts new file mode 100644 index 0000000..55f9663 --- /dev/null +++ b/server/express/tests/integration/advancedEndpoints.integration.test.ts @@ -0,0 +1,532 @@ +/** + * Advanced Endpoints Integration Tests + * + * These tests verify the advanced MongoDB API endpoints including: + * - Atlas Search + * - Vector Search + * - Aggregation pipelines ($lookup, $group, $unwind) + * + * These tests use supertest to make actual HTTP requests to the Express app, + * testing the full request/response cycle including routing, controllers, and database operations. + * + * Requirements: + * - MONGODB_URI environment variable must be set + * - MongoDB instance must be accessible + * - For Search tests: ENABLE_SEARCH_TESTS=true + * - For Vector Search tests: VOYAGE_API_KEY must be set + */ + +import request from "supertest"; +import { ObjectId } from "mongodb"; +import { app } from "../../src/app"; +import { getCollection } from "../../src/config/database"; +import { describeIntegration, describeSearch, describeVectorSearch } from "./setup"; + +describeIntegration("Advanced Endpoints Integration Tests", () => { + let testMovieIds: string[] = []; + let testCommentIds: ObjectId[] = []; + + beforeAll(async () => { + // Clean up any orphaned test data from previous failed runs + const moviesCollection = getCollection("movies"); + const commentsCollection = getCollection("comments"); + + await moviesCollection.deleteMany({ + $or: [ + { title: { $regex: /^Test Advanced Movie/ } }, + { title: { $regex: /^Test Aggregation Movie/ } }, + { title: { $regex: /^Test Director Movie/ } }, + { title: { $regex: /^Test Year Stats Movie/ } }, + ], + }); + + await commentsCollection.deleteMany({ + name: { $regex: /^Test User/ }, + }); + }); + + afterEach(async () => { + // Clean up test data after each test + if (testMovieIds.length > 0) { + const moviesCollection = getCollection("movies"); + await moviesCollection.deleteMany({ + _id: { $in: testMovieIds.map((id) => new ObjectId(id)) }, + }); + testMovieIds = []; + } + + if (testCommentIds.length > 0) { + const commentsCollection = getCollection("comments"); + await commentsCollection.deleteMany({ + _id: { $in: testCommentIds }, + }); + testCommentIds = []; + } + }); + + describe("GET /api/movies/aggregations/reportingByComments", () => { + test("should return movies with their most recent comments", async () => { + // Create test movie via API + const testMovie = { + title: "Test Aggregation Movie 1", + year: 2024, + plot: "A test movie for aggregation", + genres: ["Drama"], + imdb: { rating: 8.5 }, + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(testMovie) + .expect(201); + + const movieId = createResponse.body.data._id; + testMovieIds.push(movieId); + + // Create test comments directly in database (no API endpoint for comments) + const commentsCollection = getCollection("comments"); + const testComments = [ + { + movie_id: new ObjectId(movieId), + name: "Test User 1", + email: "test1@example.com", + text: "Great movie!", + date: new Date("2024-01-15"), + }, + { + movie_id: new ObjectId(movieId), + name: "Test User 2", + email: "test2@example.com", + text: "Loved it!", + date: new Date("2024-01-20"), + }, + { + movie_id: new ObjectId(movieId), + name: "Test User 3", + email: "test3@example.com", + text: "Amazing!", + date: new Date("2024-01-25"), + }, + ]; + + const commentsResult = await commentsCollection.insertMany(testComments); + testCommentIds.push(...Object.values(commentsResult.insertedIds)); + + // Test the API endpoint + const response = await request(app) + .get("/api/movies/aggregations/reportingByComments") + .query({ limit: 10 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(Array.isArray(response.body.data)).toBe(true); + + // Find our test movie in the results + const testMovieResult = response.body.data.find( + (m: any) => m._id === movieId + ); + + expect(testMovieResult).toBeDefined(); + expect(testMovieResult.title).toBe("Test Aggregation Movie 1"); + expect(testMovieResult.totalComments).toBe(3); + expect(testMovieResult.recentComments).toHaveLength(3); + + // Verify comments are sorted by date (most recent first) + expect(testMovieResult.recentComments[0].userName).toBe("Test User 3"); + expect(testMovieResult.recentComments[1].userName).toBe("Test User 2"); + expect(testMovieResult.recentComments[2].userName).toBe("Test User 1"); + }); + + test("should limit recent comments per movie", async () => { + // Create test movie + const testMovie = { + title: "Test Aggregation Movie Limit", + year: 2024, + plot: "A test movie", + imdb: { rating: 7.5 }, + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(testMovie) + .expect(201); + + testMovieIds.push(createResponse.body.data._id); + + // Create many comments + const commentsCollection = getCollection("comments"); + const manyComments = Array.from({ length: 10 }, (_, i) => ({ + movie_id: new ObjectId(createResponse.body.data._id), + name: `Test User ${i}`, + email: `test${i}@example.com`, + text: `Comment ${i}`, + date: new Date(`2024-01-${i + 1}`), + })); + + const commentsResult = await commentsCollection.insertMany(manyComments); + testCommentIds.push(...Object.values(commentsResult.insertedIds)); + + // Request with limit of 3 recent comments + const response = await request(app) + .get("/api/movies/aggregations/reportingByComments") + .query({ limit: 3 }) + .expect(200); + + expect(response.body.success).toBe(true); + const testMovieResult = response.body.data.find( + (m: any) => m._id === createResponse.body.data._id + ); + + if (testMovieResult) { + // Should have all 10 comments total + expect(testMovieResult.totalComments).toBe(10); + // But only 3 recent comments returned + expect(testMovieResult.recentComments.length).toBe(3); + } + }); + + test("should handle movies with no comments", async () => { + // Create a movie without comments + const testMovie = { + title: "Test Aggregation Movie No Comments", + year: 2024, + plot: "A movie with no comments", + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(testMovie) + .expect(201); + + testMovieIds.push(createResponse.body.data._id); + + // The endpoint should only return movies with comments + const response = await request(app) + .get("/api/movies/aggregations/reportingByComments") + .query({ limit: 100 }) + .expect(200); + + const movieWithNoComments = response.body.data.find( + (m: any) => m._id === createResponse.body.data._id + ); + + // Movie without comments should not be in results + expect(movieWithNoComments).toBeUndefined(); + }); + }); + + describe("GET /api/movies/aggregations/reportingByYear", () => { + test("should return movie statistics grouped by year", async () => { + // Create test movies for a specific year + const testYear = 2023; + const testMovies = [ + { + title: "Test Year Stats Movie 1", + year: testYear, + plot: "First test movie", + imdb: { rating: 8.0, votes: 1000 }, + }, + { + title: "Test Year Stats Movie 2", + year: testYear, + plot: "Second test movie", + imdb: { rating: 9.0, votes: 2000 }, + }, + ]; + + for (const movie of testMovies) { + const createResponse = await request(app) + .post("/api/movies") + .send(movie) + .expect(201); + testMovieIds.push(createResponse.body.data._id); + } + + // Test the API endpoint + const response = await request(app) + .get("/api/movies/aggregations/reportingByYear") + .query({ limit: 50 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(Array.isArray(response.body.data)).toBe(true); + + // Find our test year in the results + const yearStats = response.body.data.find( + (y: any) => y.year === testYear + ); + + expect(yearStats).toBeDefined(); + expect(yearStats.movieCount).toBeGreaterThanOrEqual(2); + expect(yearStats.averageRating).toBeDefined(); + + // Check ratings if they exist (our test movies have ratings) + if (yearStats.highestRating !== null) { + expect(yearStats.highestRating).toBeGreaterThanOrEqual(8.0); + } + if (yearStats.lowestRating !== null) { + expect(yearStats.lowestRating).toBeLessThanOrEqual(9.0); + } + }); + + test("should sort results by year in descending order", async () => { + const response = await request(app) + .get("/api/movies/aggregations/reportingByYear") + .query({ limit: 10 }) + .expect(200); + + expect(response.body.success).toBe(true); + const data = response.body.data; + + // Verify years are in descending order + for (let i = 0; i < data.length - 1; i++) { + expect(data[i].year).toBeGreaterThanOrEqual(data[i + 1].year); + } + }); + }); + + describe("GET /api/movies/aggregations/reportingByDirectors", () => { + test("should return directors with their movie counts and verify response structure", async () => { + // Test the API endpoint + const response = await request(app) + .get("/api/movies/aggregations/reportingByDirectors") + .query({ limit: 20 }) + .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 the response structure + const firstDirector = response.body.data[0]; + expect(firstDirector).toHaveProperty("director"); + expect(firstDirector).toHaveProperty("movieCount"); + expect(firstDirector).toHaveProperty("averageRating"); + + // Verify directors are sorted by movie count (descending) + expect(firstDirector.movieCount).toBeGreaterThan(0); + + // Verify all directors have the expected structure + response.body.data.forEach((director: any) => { + expect(typeof director.director).toBe("string"); + expect(typeof director.movieCount).toBe("number"); + expect(director.movieCount).toBeGreaterThan(0); + if (director.averageRating !== null) { + expect(typeof director.averageRating).toBe("number"); + } + }); + }); + }); +}); + +describeSearch("Atlas Search Integration Tests", () => { + let testMovieIds: string[] = []; + + beforeAll(async () => { + if (!process.env.ENABLE_SEARCH_TESTS) { + console.log(` +⚠️ Atlas Search tests skipped: ENABLE_SEARCH_TESTS environment variable is not set + To run Atlas Search integration tests, set ENABLE_SEARCH_TESTS=true in your .env file + Example: ENABLE_SEARCH_TESTS=true npm run test:integration +`); + return; + } + + // Clean up any orphaned test data from previous failed runs + const moviesCollection = getCollection("movies"); + await moviesCollection.deleteMany({ + title: { $regex: /^Test Search Movie/ }, + }); + }); + + afterEach(async () => { + if (!process.env.ENABLE_SEARCH_TESTS) { + return; + } + + // Clean up test data after each test + if (testMovieIds.length > 0) { + const moviesCollection = getCollection("movies"); + await moviesCollection.deleteMany({ + _id: { $in: testMovieIds.map((id) => new ObjectId(id)) }, + }); + testMovieIds = []; + } + }); + + describe("GET /api/movies/search", () => { + test("should search movies by plot using phrase matching", async () => { + // Create test movies with specific plots + const testMovies = [ + { + title: "Test Search Movie 1", + year: 2024, + plot: "A detective solving a mysterious crime in the city", + genres: ["Mystery", "Thriller"], + }, + { + title: "Test Search Movie 2", + year: 2024, + plot: "An epic space adventure across the galaxy", + genres: ["Sci-Fi", "Adventure"], + }, + ]; + + for (const movie of testMovies) { + const createResponse = await request(app) + .post("/api/movies") + .send(movie) + .expect(201); + testMovieIds.push(createResponse.body.data._id); + } + + // Wait for search index to update + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Test search for "detective solving" + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "detective solving", searchOperator: "must", limit: 10 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.movies).toBeDefined(); + expect(Array.isArray(response.body.data.movies)).toBe(true); + + // Find our test movie in the results + const foundMovie = response.body.data.movies.find( + (m: any) => m.title === "Test Search Movie 1" + ); + + if (foundMovie) { + expect(foundMovie.plot).toContain("detective solving"); + } + }); + + test("should search movies by directors", async () => { + const testMovie = { + title: "Test Search Movie Director", + year: 2024, + plot: "A test movie", + directors: ["Christopher Nolan"], + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(testMovie) + .expect(201); + testMovieIds.push(createResponse.body.data._id); + + // Wait for search index to update + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // Test search for director + const response = await request(app) + .get("/api/movies/search") + .query({ directors: "Christopher Nolan", limit: 10 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.movies).toBeDefined(); + }); + + test("should support pagination with skip and limit", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "love", limit: 5, skip: 0 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.movies.length).toBeLessThanOrEqual(5); + expect(response.body.data.totalCount).toBeDefined(); + }); + + test("should return error when no search parameters provided", async () => { + const response = await request(app) + .get("/api/movies/search") + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + }); +}); + + +describeVectorSearch("Vector Search Integration Tests", () => { + let testMovieIds: string[] = []; + + afterEach(async () => { + // Clean up test data after each test + if (testMovieIds.length > 0) { + const moviesCollection = getCollection("movies"); + await moviesCollection.deleteMany({ + _id: { $in: testMovieIds.map((id) => new ObjectId(id)) }, + }); + testMovieIds = []; + } + }); + + describe("GET /api/movies/vector-search", () => { + test("should perform vector search and return similar movies", async () => { + const response = await request(app) + .get("/api/movies/vector-search") + .query({ q: "space adventure", limit: 5 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(Array.isArray(response.body.data)).toBe(true); + + // If results are returned, verify structure + if (response.body.data.length > 0) { + const firstResult = response.body.data[0]; + expect(firstResult).toHaveProperty("_id"); + expect(firstResult).toHaveProperty("title"); + expect(firstResult).toHaveProperty("score"); + expect(typeof firstResult.score).toBe("number"); + + // Verify scores are in descending order + for (let i = 0; i < response.body.data.length - 1; i++) { + expect(response.body.data[i].score).toBeGreaterThanOrEqual( + response.body.data[i + 1].score + ); + } + } + }); + + test("should respect limit parameter", async () => { + const limit = 3; + const response = await request(app) + .get("/api/movies/vector-search") + .query({ q: "detective mystery", limit }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.length).toBeLessThanOrEqual(limit); + }); + + test("should return error when query parameter is missing", async () => { + const response = await request(app) + .get("/api/movies/vector-search") + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + + test("should handle empty query string", async () => { + const response = await request(app) + .get("/api/movies/vector-search") + .query({ q: "" }) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); +}); diff --git a/server/express/tests/integration/mongodbSearch.integration.test.ts b/server/express/tests/integration/mongodbSearch.integration.test.ts new file mode 100644 index 0000000..614a1de --- /dev/null +++ b/server/express/tests/integration/mongodbSearch.integration.test.ts @@ -0,0 +1,166 @@ +/** + * MongoDB Atlas Search API Integration Tests + * + * These tests verify the Atlas Search API endpoints with actual HTTP requests. + * The tests require: + * - A MongoDB Atlas instance with Search enabled + * - MONGODB_URI environment variable + * - ENABLE_SEARCH_TESTS=true environment variable to enable tests + * - movieSearchIndex must be configured in Atlas + * + * Note: These tests are disabled by default and should only be run against a test MongoDB Atlas instance. + */ + +import request from "supertest"; +import { app } from "../../src/app"; +import { describeSearch } from "./setup"; + +describeSearch("MongoDB Atlas Search API Integration Tests", () => { + + describe("GET /api/movies/search - Search by plot", () => { + test("should find movies with plot search", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "detective mystery" }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.movies).toBeDefined(); + expect(Array.isArray(response.body.data.movies)).toBe(true); + expect(response.body.data.totalCount).toBeDefined(); + }); + + test("should return empty results when no movies match search query", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "xyzabc123nonexistent" }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.movies).toBeDefined(); + expect(response.body.data.movies.length).toBe(0); + expect(response.body.data.totalCount).toBe(0); + }); + }); + + describe("GET /api/movies/search - Search by directors", () => { + test("should find movies by director name", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ directors: "Spielberg" }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.movies).toBeDefined(); + expect(Array.isArray(response.body.data.movies)).toBe(true); + }); + }); + + describe("GET /api/movies/search - Search by cast", () => { + test("should find movies by cast member", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ cast: "Tom Hanks" }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.movies).toBeDefined(); + expect(Array.isArray(response.body.data.movies)).toBe(true); + }); + }); + + describe("GET /api/movies/search - Pagination", () => { + test("should respect limit parameter", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "adventure", limit: 5 }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.movies).toBeDefined(); + expect(response.body.data.movies.length).toBeLessThanOrEqual(5); + }); + + test("should support pagination with skip and limit", async () => { + // Get first page + const firstPage = await request(app) + .get("/api/movies/search") + .query({ plot: "adventure", limit: 5, skip: 0 }) + .expect(200); + + // Get second page + const secondPage = await request(app) + .get("/api/movies/search") + .query({ plot: "adventure", limit: 5, skip: 5 }) + .expect(200); + + expect(firstPage.body.success).toBe(true); + expect(secondPage.body.success).toBe(true); + + // If we have enough results, verify different pages + if ( + firstPage.body.data.movies.length > 0 && + secondPage.body.data.movies.length > 0 + ) { + const firstPageIds = firstPage.body.data.movies.map((m: any) => m._id); + const secondPageIds = secondPage.body.data.movies.map((m: any) => m._id); + + // Verify no overlap between pages + const hasOverlap = firstPageIds.some((id: string) => + secondPageIds.includes(id) + ); + expect(hasOverlap).toBe(false); + } + }); + }); + + describe("GET /api/movies/search - Search operators", () => { + test("should support compound search with must operator", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "detective", directors: "Nolan", searchOperator: "must" }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + }); + + test("should support compound search with should operator", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ + plot: "adventure", + cast: "Harrison Ford", + searchOperator: "should", + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + }); + }); + + describe("GET /api/movies/search - Error handling", () => { + test("should return 400 when no search parameters provided", async () => { + const response = await request(app) + .get("/api/movies/search") + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + + test("should return 400 for invalid search operator", async () => { + const response = await request(app) + .get("/api/movies/search") + .query({ plot: "adventure", searchOperator: "invalid" }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/server/express/tests/integration/movie.integration.test.ts b/server/express/tests/integration/movie.integration.test.ts new file mode 100644 index 0000000..3090e98 --- /dev/null +++ b/server/express/tests/integration/movie.integration.test.ts @@ -0,0 +1,482 @@ +/** + * Movie CRUD Integration Tests + * + * These tests verify the full API functionality for movie CRUD operations. + * Unlike unit tests, these tests make actual HTTP requests to the Express app + * and connect to a real MongoDB instance. + * + * Requirements: + * - MONGODB_URI environment variable must be set + * - MongoDB instance must be accessible + */ + +import request from "supertest"; +import { ObjectId } from "mongodb"; +import { app } from "../../src/app"; +import { getCollection } from "../../src/config/database"; +import { describeIntegration } from "./setup"; + +describeIntegration("Movie CRUD API Integration Tests", () => { + let testMovieIds: string[] = []; + + beforeAll(async () => { + // Clean up any orphaned test data from previous failed runs + // This ensures tests are idempotent + const moviesCollection = getCollection("movies"); + await moviesCollection.deleteMany({ + $or: [ + { title: { $regex: /^Integration Test Movie/ } }, + { title: { $regex: /^Find By ID Test Movie/ } }, + { title: { $regex: /^Action Movie 202[0-9]/ } }, + { title: { $regex: /^Drama Movie 202[0-9]/ } }, + { title: { $regex: /^Pagination Test Movie/ } }, + { title: { $regex: /^Original Title/ } }, + { title: { $regex: /^Updated Title/ } }, + { title: { $regex: /^Movie [0-9]/ } }, + { title: { $regex: /^Movie to Delete/ } }, + { title: { $regex: /^Delete Test/ } }, + { title: { $regex: /^Find and Delete Test/ } }, + { title: { $regex: /^Batch Test Movie/ } }, + ], + }); + }); + + afterEach(async () => { + // Clean up test movies after each test + if (testMovieIds.length > 0) { + const moviesCollection = getCollection("movies"); + await moviesCollection.deleteMany({ + _id: { $in: testMovieIds.map((id) => new ObjectId(id)) }, + }); + testMovieIds = []; + } + }); + + describe("POST /api/movies - Create Single Movie", () => { + test("should create a single movie", async () => { + const newMovie = { + title: "Integration Test Movie", + year: 2024, + plot: "A movie created during integration testing", + genres: ["Test", "Drama"], + }; + + const response = await request(app) + .post("/api/movies") + .send(newMovie) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data._id).toBeDefined(); + expect(response.body.data.title).toBe(newMovie.title); + expect(response.body.data.year).toBe(newMovie.year); + expect(response.body.data.plot).toBe(newMovie.plot); + expect(response.body.data.genres).toEqual(newMovie.genres); + + testMovieIds.push(response.body.data._id.toString()); + }); + + test("should return 400 when title is missing", async () => { + const invalidMovie = { + year: 2024, + plot: "Missing title", + }; + + const response = await request(app) + .post("/api/movies") + .send(invalidMovie) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + }); + + describe("POST /api/movies/batch - Create Multiple Movies", () => { + test("should create multiple movies", async () => { + const newMovies = [ + { + title: "Batch Test Movie 1", + year: 2024, + plot: "First test movie", + genres: ["Test"], + }, + { + title: "Batch Test Movie 2", + year: 2024, + plot: "Second test movie", + genres: ["Test"], + }, + { + title: "Batch Test Movie 3", + year: 2024, + plot: "Third test movie", + genres: ["Test"], + }, + ]; + + const response = await request(app) + .post("/api/movies/batch") + .send(newMovies) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.insertedCount).toBe(3); + expect(response.body.data.insertedIds).toBeDefined(); + expect(Object.keys(response.body.data.insertedIds).length).toBe(3); + + // Extract IDs from the insertedIds object + const ids = Object.values(response.body.data.insertedIds) as string[]; + testMovieIds.push(...ids); + }); + + test("should return 400 when request body is not an array", async () => { + const response = await request(app) + .post("/api/movies/batch") + .send({ title: "Single Movie" }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + }); + + describe("GET /api/movies/:id - Get Movie by ID", () => { + test("should get a movie by ID", async () => { + // Create a test movie + const newMovie = { + title: "Find By ID Test Movie", + year: 2024, + plot: "Testing get by ID", + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(newMovie) + .expect(201); + + const movieId = createResponse.body.data._id.toString(); + testMovieIds.push(movieId); + + // Get the movie by ID + const response = await request(app) + .get(`/api/movies/${movieId}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data._id).toBe(movieId); + expect(response.body.data.title).toBe(newMovie.title); + }); + + test("should return 404 for non-existent movie", async () => { + const fakeId = new ObjectId().toString(); + + const response = await request(app) + .get(`/api/movies/${fakeId}`) + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + + test("should return 400 for invalid ObjectId format", async () => { + const response = await request(app) + .get("/api/movies/invalid-id") + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.error).toBeDefined(); + }); + }); + + describe("GET /api/movies - Get All Movies with Filters", () => { + test("should get movies with year filter", async () => { + // Create a test movie with a specific year + const testMovie = { + title: "Action Movie 2024", + year: 2024, + genres: ["Action"], + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(testMovie) + .expect(201); + + testMovieIds.push(createResponse.body.data._id.toString()); + + // Get movies from 2024 + const response = await request(app) + .get("/api/movies") + .query({ year: 2024 }) + .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 all returned movies have year 2024 + const allHaveCorrectYear = response.body.data.every( + (m: any) => m.year === 2024 + ); + expect(allHaveCorrectYear).toBe(true); + }); + + test("should get movies with genre filter", async () => { + // Create a test movie with a specific genre + const testMovie = { + title: "Action Movie 2024", + year: 2024, + genres: ["Action", "Thriller"], + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(testMovie) + .expect(201); + + testMovieIds.push(createResponse.body.data._id.toString()); + + // Get action movies + const response = await request(app) + .get("/api/movies") + .query({ genre: "Action" }) + .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 all returned movies have "Action" in their genres + const allHaveActionGenre = response.body.data.every((m: any) => + m.genres && m.genres.some((g: string) => /action/i.test(g)) + ); + expect(allHaveActionGenre).toBe(true); + }); + + test("should support pagination with limit and skip", async () => { + const response = await request(app) + .get("/api/movies") + .query({ limit: 5, skip: 0 }) + .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).toBeLessThanOrEqual(5); + }); + }); + + describe("PATCH /api/movies/:id - Update Single Movie", () => { + test("should update a single movie", async () => { + // Create a test movie + const newMovie = { + title: "Original Title", + year: 2024, + plot: "Original plot", + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(newMovie) + .expect(201); + + const movieId = createResponse.body.data._id.toString(); + testMovieIds.push(movieId); + + // Update the movie + const updateData = { + title: "Updated Title", + plot: "Updated plot", + }; + + const response = await request(app) + .patch(`/api/movies/${movieId}`) + .send(updateData) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.title).toBe("Updated Title"); + expect(response.body.data.plot).toBe("Updated plot"); + expect(response.body.data.year).toBe(2024); // Unchanged field + }); + + test("should return 404 for non-existent movie", async () => { + const fakeId = new ObjectId().toString(); + + const response = await request(app) + .patch(`/api/movies/${fakeId}`) + .send({ title: "Updated" }) + .expect(404); + + expect(response.body.success).toBe(false); + }); + }); + + describe("PATCH /api/movies - Update Multiple Movies", () => { + test("should update multiple movies", async () => { + // Create test movies + const testMovies = [ + { title: "Movie 1", year: 2024, rated: "PG" }, + { title: "Movie 2", year: 2024, rated: "PG" }, + { title: "Movie 3", year: 2024, rated: "R" }, + ]; + + const createdIds: string[] = []; + for (const movie of testMovies) { + const createResponse = await request(app) + .post("/api/movies") + .send(movie) + .expect(201); + createdIds.push(createResponse.body.data._id.toString()); + testMovieIds.push(createResponse.body.data._id.toString()); + } + + // Update all PG movies to PG-13 + const response = await request(app) + .patch("/api/movies") + .send({ + filter: { _id: { $in: createdIds }, rated: "PG" }, + update: { rated: "PG-13" }, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.modifiedCount).toBe(2); + }); + }); + + describe("DELETE /api/movies/:id - Delete Single Movie", () => { + test("should delete a single movie", async () => { + // Create a test movie + const newMovie = { + title: "Movie to Delete", + year: 2024, + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(newMovie) + .expect(201); + + const movieId = createResponse.body.data._id.toString(); + testMovieIds.push(movieId); + + // Delete the movie + const response = await request(app) + .delete(`/api/movies/${movieId}`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.deletedCount).toBe(1); + + // Verify deletion - should return 404 + await request(app).get(`/api/movies/${movieId}`).expect(404); + + // Remove from tracking since it's successfully deleted + testMovieIds = testMovieIds.filter((id) => id !== movieId); + }); + + test("should return 404 when deleting non-existent movie", async () => { + const fakeId = new ObjectId().toString(); + + const response = await request(app) + .delete(`/api/movies/${fakeId}`) + .expect(404); + + expect(response.body.success).toBe(false); + }); + }); + + describe("DELETE /api/movies/:id/find-and-delete - Find and Delete", () => { + test("should atomically find and delete a movie", async () => { + // Create a test movie + const newMovie = { + title: "Find and Delete Test", + year: 2024, + plot: "Testing find and delete", + }; + + const createResponse = await request(app) + .post("/api/movies") + .send(newMovie) + .expect(201); + + const movieId = createResponse.body.data._id.toString(); + testMovieIds.push(movieId); + + // Find and delete + const response = await request(app) + .delete(`/api/movies/${movieId}/find-and-delete`) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data._id).toBe(movieId); + expect(response.body.data.title).toBe(newMovie.title); + + // Verify deletion + await request(app).get(`/api/movies/${movieId}`).expect(404); + + // Remove from tracking + testMovieIds = testMovieIds.filter((id) => id !== movieId); + }); + }); + + describe("DELETE /api/movies - Delete Multiple Movies", () => { + test("should delete multiple movies", async () => { + // Create test movies + const testMovies = [ + { title: "Delete Test 1", year: 2024 }, + { title: "Delete Test 2", year: 2024 }, + { title: "Delete Test 3", year: 2024 }, + ]; + + const createdIds: string[] = []; + for (const movie of testMovies) { + const createResponse = await request(app) + .post("/api/movies") + .send(movie) + .expect(201); + createdIds.push(createResponse.body.data._id.toString()); + testMovieIds.push(createResponse.body.data._id.toString()); + } + + // Delete all test movies using filter + const response = await request(app) + .delete("/api/movies") + .send({ filter: { _id: { $in: createdIds } } }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.deletedCount).toBe(3); + + // Verify deletion + for (const id of createdIds) { + await request(app).get(`/api/movies/${id}`).expect(404); + } + + // Remove from tracking + testMovieIds = testMovieIds.filter((id) => !createdIds.includes(id)); + }); + + test("should return 400 when filter is missing", async () => { + const response = await request(app) + .delete("/api/movies") + .send({}) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); +}); + diff --git a/server/express/tests/integration/setup.ts b/server/express/tests/integration/setup.ts new file mode 100644 index 0000000..bd628ea --- /dev/null +++ b/server/express/tests/integration/setup.ts @@ -0,0 +1,129 @@ +/** + * Integration Test Setup + * + * This file runs before all integration tests and sets up the test environment. + * Unlike unit tests, integration tests connect to a real MongoDB instance. + */ + +import { connectToDatabase, closeDatabaseConnection } from "../../src/config/database"; +import dotenv from "dotenv"; + +// Set test environment variables +dotenv.config(); +process.env.NODE_ENV = "test"; + +// Increase timeout for database operations in integration tests +// Integration tests may take longer due to network calls and index creation +jest.setTimeout(120000); // 2 minutes + +/** + * Check if integration tests should run + * Integration tests are skipped unless MONGODB_URI is set + */ +export function isIntegrationTestEnabled(): boolean { + return !!process.env.MONGODB_URI; +} + +/** + * Check if Search tests should run + * Search tests require MongoDB with Search enabled and are opt-in via environment variable + */ +export function isSearchTestEnabled(): boolean { + return process.env.ENABLE_SEARCH_TESTS === "true" && isIntegrationTestEnabled(); +} + +/** + * Check if Vector Search tests should run + * Vector Search tests require VOYAGE_API_KEY environment variable + */ +export function isVectorSearchEnabled(): boolean { + return !!process.env.VOYAGE_API_KEY && process.env.VOYAGE_API_KEY.trim().length > 0; +} + +// Track whether we've already shown the skip messages to avoid duplicates +let hasShownIntegrationSkipMessage = false; +let hasShownSearchSkipMessage = false; + +/** + * Get the appropriate describe function based on whether integration tests are enabled + * Usage: describeIntegration("My Test Suite", () => { ... }) + * This will skip the entire suite if MONGODB_URI is not set + */ +export const describeIntegration: jest.Describe = isIntegrationTestEnabled() + ? describe + : ((...args: Parameters) => { + if (!hasShownIntegrationSkipMessage) { + console.log(` +⚠️ Integration tests skipped: MONGODB_URI environment variable is not set + To run integration tests, set MONGODB_URI to your MongoDB connection string + Example: MONGODB_URI=mongodb://localhost:27017/sample_mflix npm run test:integration +`); + hasShownIntegrationSkipMessage = true; + } + return describe.skip(...args); + }) as jest.Describe; + +/** + * Get the appropriate describe function based on whether search tests are enabled + * Usage: describeSearch("My Search Test Suite", () => { ... }) + * This will skip the entire suite if ENABLE_SEARCH_TESTS is not set + */ +export const describeSearch: jest.Describe = isSearchTestEnabled() + ? describe + : ((...args: Parameters) => { + if (!hasShownSearchSkipMessage) { + console.log(` +⚠️ Search tests skipped: ENABLE_SEARCH_TESTS environment variable is not set + To run Search integration tests, set ENABLE_SEARCH_TESTS=true + Example: MONGODB_URI=mongodb://localhost:27017/sample_mflix ENABLE_SEARCH_TESTS=true npm run test:integration +`); + hasShownSearchSkipMessage = true; + } + return describe.skip(...args); + }) as jest.Describe; + +/** + * Conditional describe for Vector Search tests + */ +/** + * Get the appropriate describe function based on whether Vector Search tests are enabled + * Usage: describeVectorSearch("My Vector Search Test Suite", () => { ... }) + * This will skip the entire suite if VOYAGE_API_KEY is not set + */ +export const describeVectorSearch: jest.Describe = isVectorSearchEnabled() + ? describe + : ((...args: Parameters) => { + if (!hasShownSearchSkipMessage) { + console.log(` +⚠️ Vector Search tests skipped: VOYAGE_API_KEY environment variable is not set + To run Vector Search integration tests, set VOYAGE_API_KEY in your .env file + Example: VOYAGE_API_KEY=your-api-key npm run test:integration +`); + hasShownSearchSkipMessage = true; + } + return describe.skip(...args); + }) as jest.Describe; + +// Global setup - runs once before all tests +beforeAll(async () => { + try { + await connectToDatabase(); + } catch (error) { + console.error("❌ Failed to connect to MongoDB:", error); + throw error; + } +}); + +// Global teardown - runs once after all tests +afterAll(async () => { + if (!isIntegrationTestEnabled()) { + return; + } + + try { + await closeDatabaseConnection(); + } catch (error) { + console.error("❌ Failed to close MongoDB connection:", error); + } +}); +