Spaces:
Sleeping
Sleeping
| """ | |
| 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"}), | |
| ] | |
| def fake_repo(tmp_path): | |
| (tmp_path / "main.py").write_text("def hello(): return 'world'\n") | |
| return str(tmp_path) | |
| 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}" | |
| ) |