ai-code-review-agent / tests /integration /test_analyze_integration.py
Padmanav's picture
fix: correct all async route status code assertions (POST=202, GET poll=200)
5e09fb9
Raw
History Blame Contribute Delete
15.5 kB
"""
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}"
)