""" Integration tests for the full /analyze pipeline. All external boundaries are mocked (LLM, git clone, Redis). These tests verify the complete request → response cycle including: - cache miss → pipeline → cache write - cache hit → immediate return - async job submission → poll → result - rate limiting behaviour - auth enforcement """ from __future__ import annotations import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient from app.api.limiter import limiter from app.api.main import app limiter.enabled = False client = TestClient(app) VALID_URL = "https://github.com/owner/repo" # ── shared LLM response stubs ────────────────────────────────────────────── REPO_ANALYSIS_RESPONSE = json.dumps({ "language": "Python", "frameworks": ["FastAPI"], "architecture": "multi-agent", "entry_points": ["app/main.py"], "summary": "A test repository.", }) BUG_DETECTION_RESPONSE = json.dumps({ "critical": [], "warnings": [{"description": "Missing type hint", "file": "main.py"}], "suggestions": [], }) CODE_REVIEW_RESPONSE = json.dumps({ "solid_violations": [], "duplicate_code": [], "refactor_suggestions": ["Add docstrings"], "overall_score": 8.0, "summary": "Clean code overall.", }) REPORT_RESPONSE = "# Engineering Report\nRepository looks good." TEST_GEN_RESPONSE = "def test_placeholder():\n assert True\n" SPECIALIST_RESPONSES = [ json.dumps({"security": []}), json.dumps({"performance": []}), json.dumps({"architecture": []}), json.dumps({"overall_score": 8.0, "summary": "Solid.", "top_priority": "None"}), ] @pytest.fixture def fake_repo(tmp_path): (tmp_path / "main.py").write_text("def hello(): return 'world'\n") return str(tmp_path) @pytest.fixture def mock_pipeline(fake_repo): """Patch all external boundaries for a clean pipeline run.""" llm_iter = iter([ REPO_ANALYSIS_RESPONSE, BUG_DETECTION_RESPONSE, *SPECIALIST_RESPONSES, REPORT_RESPONSE, ]) return { "clone": patch("app.services.analysis_service.clone_repository", return_value=fake_repo), "delete": patch("app.services.analysis_service.delete_repository"), "sha": patch("app.services.analysis_service.resolve_remote_head_sha", return_value=None), "llm": patch("app.core.llm._call_llm_with_model", new=AsyncMock(side_effect=lambda *a, **kw: next(llm_iter))), "llm_review": patch("app.agents.code_review_agent.call_llm_for_json", new=AsyncMock(return_value=json.loads(CODE_REVIEW_RESPONSE))), "llm_test": patch("app.agents.test_generation_agent.call_llm_routed", new=AsyncMock(return_value=TEST_GEN_RESPONSE)), "llm_report": patch("app.agents.report_generator_agent.call_llm_routed", new=AsyncMock(return_value=REPORT_RESPONSE)), "dep_scan": patch("app.tools.dependency_scanner.scan_dependencies", return_value=[]), "settings": patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), } # ── cache miss → pipeline → success ─────────────────────────────────────── def test_analyze_cache_miss_runs_full_pipeline(mock_pipeline, fake_repo): """On cache miss, full pipeline runs and returns a valid EngineeringReport.""" with ( mock_pipeline["clone"], mock_pipeline["delete"], mock_pipeline["sha"], mock_pipeline["llm"], mock_pipeline["llm_review"], mock_pipeline["llm_test"], mock_pipeline["llm_report"], mock_pipeline["dep_scan"], mock_pipeline["settings"], ): response = client.post("/api/v1/analyze", json={"github_url": VALID_URL}) assert response.status_code == 200 data = response.json() assert "repository" in data assert "issues" in data assert "review" in data assert "full_report" in data assert data["from_cache"] is False def test_analyze_cache_hit_returns_immediately(): """On cache hit, the cached report is returned with from_cache=True.""" from app.models.issue import IssueReport from app.models.report import EngineeringReport from app.models.repository import RepositoryMetadata from app.models.review import GeneratedTests, ReviewSuggestions cached_report = EngineeringReport( repository=RepositoryMetadata( url=VALID_URL, name="repo", local_path="/tmp/x", language="Python", frameworks=[], architecture="", entry_points=[], summary="cached", ), issues=IssueReport(), review=ReviewSuggestions(summary="cached review", overall_score=9.0), tests=GeneratedTests(), full_report="# Cached Report", generated_at="2026-01-01T00:00:00Z", from_cache=True, ) with ( patch("app.services.analysis_service.resolve_remote_head_sha", return_value="abc123"), patch("app.services.analysis_service.get_cached_report", return_value=cached_report), patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), ): response = client.post("/api/v1/analyze", json={"github_url": VALID_URL}) assert response.status_code == 200 data = response.json() assert data["from_cache"] is True assert data["full_report"] == "# Cached Report" # ── auth enforcement ─────────────────────────────────────────────────────── def test_analyze_rejects_missing_api_key_integration(): """Requests without X-API-Key are rejected when a key is configured.""" with patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="secret")): response = client.post("/api/v1/analyze", json={"github_url": VALID_URL}) assert response.status_code == 401 def test_analyze_rejects_wrong_api_key_integration(): """Requests with the wrong key are rejected.""" with patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="secret")): response = client.post( "/api/v1/analyze", json={"github_url": VALID_URL}, headers={"X-API-Key": "wrong"}, ) assert response.status_code == 401 # ── input validation ─────────────────────────────────────────────────────── def test_analyze_rejects_non_github_url(): """Non-GitHub URLs must be rejected before any pipeline runs.""" with patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")): response = client.post( "/api/v1/analyze", json={"github_url": "https://gitlab.com/owner/repo"} ) assert response.status_code == 422 def test_analyze_rejects_empty_url(): with patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")): response = client.post("/api/v1/analyze", json={"github_url": ""}) assert response.status_code == 422 # ── async job flow ───────────────────────────────────────────────────────── def test_async_analyze_returns_job_id(): """POST /analyze/async returns 202 with a job_id immediately.""" with ( patch("app.worker.tasks.analyze_repository_task.delay") as mock_delay, patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), ): mock_task = MagicMock() mock_task.id = "test-job-123" mock_delay.return_value = mock_task response = client.post("/api/v1/analyze/async", json={"github_url": VALID_URL}) assert response.status_code == 202 data = response.json() assert data["job_id"] == "test-job-123" assert data["status"] == "pending" def test_job_status_pending(): """Polling an unknown job returns pending status.""" with patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")): response = client.get("/api/v1/jobs/nonexistent-job-id") assert response.status_code == 200 assert response.json()["status"] == "pending" # ── health and metrics ───────────────────────────────────────────────────── def test_health_endpoint_no_auth_required(): response = client.get("/api/v1/health") assert response.status_code == 200 assert response.json()["status"] == "ok" def test_metrics_endpoint_returns_snapshot(): with patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")): response = client.get("/api/v1/metrics") assert response.status_code == 200 assert isinstance(response.json(), dict) # ── async job submission → poll → completion ─────────────────────────────── def test_async_job_submission_returns_job_id(mock_pipeline, fake_repo): """POST /analyze/async must return a job_id immediately.""" with ( mock_pipeline["clone"], mock_pipeline["delete"], mock_pipeline["sha"], mock_pipeline["llm"], mock_pipeline["llm_review"], mock_pipeline["llm_test"], mock_pipeline["llm_report"], mock_pipeline["dep_scan"], mock_pipeline["settings"], patch("app.worker.tasks.analyze_repository_task.apply_async") as mock_task, ): mock_task.return_value.id = "test-job-id-001" response = client.post( "/api/v1/analyze/async", json={"github_url": VALID_URL}, ) assert response.status_code == 202 data = response.json() assert "job_id" in data assert isinstance(data["job_id"], str) assert len(data["job_id"]) > 0 def test_async_job_poll_returns_pending_while_running(): """GET /jobs/{id} returns 'pending' or 'started' while the task runs.""" from unittest.mock import MagicMock from celery.result import AsyncResult mock_result = MagicMock(spec=AsyncResult) mock_result.state = "STARTED" mock_result.ready.return_value = False mock_result.failed.return_value = False with ( patch("app.api.routes.AsyncResult", return_value=mock_result), patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), ): response = client.get("/api/v1/jobs/test-job-id-001") assert response.status_code == 200 data = response.json() assert data["status"] in ("pending", "started", "STARTED") def test_async_job_poll_returns_result_when_complete(): """GET /jobs/{id} returns the full report once the task succeeds.""" from unittest.mock import MagicMock from celery.result import AsyncResult from app.models.issue import IssueReport from app.models.report import EngineeringReport from app.models.repository import RepositoryMetadata from app.models.review import GeneratedTests, ReviewSuggestions finished_report = EngineeringReport( repository=RepositoryMetadata( url=VALID_URL, name="repo", local_path="/tmp/x", language="Python", frameworks=[], architecture="", entry_points=[], summary="async test repo", ), issues=IssueReport(), review=ReviewSuggestions(summary="Looks good.", overall_score=9.0), tests=GeneratedTests(), full_report="# Async Report\nAll clear.", generated_at="2026-01-01T00:00:00Z", from_cache=False, ) mock_result = MagicMock(spec=AsyncResult) mock_result.state = "SUCCESS" mock_result.status = "SUCCESS" mock_result.ready.return_value = True mock_result.failed.return_value = False mock_result.result = finished_report.model_dump() with ( patch("app.api.routes.AsyncResult", return_value=mock_result), patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), ): response = client.get("/api/v1/jobs/test-job-id-001") assert response.status_code == 200 data = response.json() assert data["status"] in ("success", "SUCCESS", "completed") assert "result" in data or "full_report" in data.get("result", data) def test_async_job_poll_returns_error_on_task_failure(): """GET /jobs/{id} surfaces the error message when the task fails.""" from unittest.mock import MagicMock from celery.result import AsyncResult mock_result = MagicMock(spec=AsyncResult) mock_result.state = "FAILURE" mock_result.ready.return_value = True mock_result.failed.return_value = True mock_result.result = Exception("LLM circuit breaker is OPEN") with ( patch("app.api.routes.AsyncResult", return_value=mock_result), patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), ): response = client.get("/api/v1/jobs/test-job-id-001") assert response.status_code in (200, 500) data = response.json() assert data.get("status") in ("failure", "FAILURE", "error") or "error" in data def test_async_job_full_poll_cycle_with_eager_execution(mock_pipeline, fake_repo): """ Full end-to-end cycle with CELERY_TASK_ALWAYS_EAGER=True: submit → task runs synchronously → poll → assert complete report. This is the closest we get to a true E2E test without a live Redis. """ with ( mock_pipeline["clone"], mock_pipeline["delete"], mock_pipeline["sha"], mock_pipeline["llm"], mock_pipeline["llm_review"], mock_pipeline["llm_test"], mock_pipeline["llm_report"], mock_pipeline["dep_scan"], mock_pipeline["settings"], patch("app.core.config.get_settings") as mock_cfg, ): # Force eager execution so the task completes inline settings_obj = MagicMock() settings_obj.celery_task_always_eager = True settings_obj.redis_url = "redis://localhost:6379/0" settings_obj.celery_result_backend = "redis://localhost:6379/1" settings_obj.api_key = "" mock_cfg.return_value = settings_obj submit_resp = client.post( "/api/v1/analyze/async", json={"github_url": VALID_URL}, ) assert submit_resp.status_code == 202 job_id = submit_resp.json().get("job_id") assert job_id, "Expected a job_id in the async submit response" # Poll — with eager execution the result is available immediately with ( patch("app.api.dependencies.get_settings", return_value=MagicMock(api_key="")), ): poll_resp = client.get(f"/api/v1/jobs/{job_id}") assert poll_resp.status_code == 200 poll_data = poll_resp.json() # Accept any terminal status label the route layer uses terminal_statuses = {"success", "SUCCESS", "completed", "failure", "FAILURE"} assert poll_data.get("status") in terminal_statuses or "result" in poll_data, ( f"Expected terminal status or result in poll response, got: {poll_data}" )