1154 lines
41 KiB
Python
1154 lines
41 KiB
Python
"""Tests for the API endpoints."""
|
|
|
|
from datetime import UTC, datetime
|
|
from unittest.mock import patch
|
|
from uuid import uuid4
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from arbiter.api.routes.health import record_review_completed
|
|
from arbiter.db.models import (
|
|
ConversationMessageModel,
|
|
ConversationModel,
|
|
DeliberationStepModel,
|
|
ReviewModel,
|
|
)
|
|
from arbiter.deliberation.coordinator import StepType
|
|
from tests.conftest import MockRedis
|
|
|
|
|
|
class TestHealthEndpoints:
|
|
async def test_liveness_check(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "healthy"
|
|
assert "version" in data
|
|
|
|
async def test_liveness_probe(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/health/live")
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "alive"
|
|
|
|
|
|
class TestWebhookSignatureValidation:
|
|
def test_github_signature_verification(self) -> None:
|
|
import hashlib
|
|
import hmac
|
|
|
|
from arbiter.api.routes.webhooks import _verify_github_signature
|
|
|
|
secret = "test-secret"
|
|
payload = b'{"test": "data"}'
|
|
expected_sig = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
|
|
|
|
assert _verify_github_signature(payload, expected_sig, secret)
|
|
assert not _verify_github_signature(payload, "sha256=invalid", secret)
|
|
assert not _verify_github_signature(payload, None, secret)
|
|
assert not _verify_github_signature(payload, "invalid-format", secret)
|
|
|
|
def test_gitlab_token_verification(self) -> None:
|
|
from arbiter.api.routes.webhooks import _verify_gitlab_token
|
|
|
|
assert _verify_gitlab_token("correct-token", "correct-token")
|
|
assert not _verify_gitlab_token("wrong-token", "correct-token")
|
|
assert not _verify_gitlab_token(None, "correct-token")
|
|
|
|
|
|
class TestApiResponseModels:
|
|
def test_finding_response_model(self) -> None:
|
|
from arbiter.api.routes.reviews import FindingResponse
|
|
|
|
finding = FindingResponse(
|
|
id="test-id",
|
|
agent="security",
|
|
file="src/test.py",
|
|
line_start=10,
|
|
line_end=15,
|
|
severity="high",
|
|
confidence=0.9,
|
|
title="Test finding",
|
|
description="Test description",
|
|
reasoning="Test reasoning",
|
|
suggestion="Test suggestion",
|
|
references=["http://example.com"],
|
|
prompt_version="security-v1.0",
|
|
)
|
|
|
|
assert finding.id == "test-id"
|
|
assert finding.severity == "high"
|
|
assert finding.confidence == 0.9
|
|
|
|
def test_review_summary_model(self) -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
from arbiter.api.routes.reviews import ReviewSummary
|
|
|
|
summary = ReviewSummary(
|
|
id="test-id",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
pr_title="Test PR",
|
|
author="testuser",
|
|
status="completed",
|
|
verdict="comment",
|
|
verdict_confidence=0.75,
|
|
finding_count=5,
|
|
critical_count=0,
|
|
high_count=2,
|
|
total_cost_usd=0.015,
|
|
created_at=datetime.now(UTC),
|
|
completed_at=datetime.now(UTC),
|
|
)
|
|
|
|
assert summary.repository == "owner/repo"
|
|
assert summary.pr_number == 42
|
|
assert summary.finding_count == 5
|
|
|
|
def test_manual_review_request_validation(self) -> None:
|
|
from arbiter.api.routes.reviews import ManualReviewRequest
|
|
|
|
# Valid request
|
|
request = ManualReviewRequest(
|
|
repository="owner/repo",
|
|
pr_number=1,
|
|
base_sha="abc1234",
|
|
head_sha="def5678",
|
|
diff_content="test diff",
|
|
)
|
|
assert request.repository == "owner/repo"
|
|
|
|
# Invalid SHA (too short)
|
|
with pytest.raises(ValueError):
|
|
ManualReviewRequest(
|
|
repository="owner/repo",
|
|
pr_number=1,
|
|
base_sha="abc", # Too short
|
|
head_sha="def5678",
|
|
)
|
|
|
|
|
|
class TestMockRedis:
|
|
async def test_mock_redis_basic_operations(self) -> None:
|
|
redis = MockRedis()
|
|
|
|
# Test set and get
|
|
await redis.set("key", "value")
|
|
result = await redis.get("key")
|
|
assert result == "value"
|
|
|
|
# Test get nonexistent key
|
|
result = await redis.get("nonexistent")
|
|
assert result is None
|
|
|
|
# Test delete
|
|
deleted = await redis.delete("key")
|
|
assert deleted == 1
|
|
deleted = await redis.delete("key")
|
|
assert deleted == 0
|
|
|
|
# Test ping
|
|
assert await redis.ping() is True
|
|
|
|
# Test llen
|
|
length = await redis.llen("queue")
|
|
assert length == 0
|
|
|
|
|
|
class TestDeliberationLogResponse:
|
|
def test_deliberation_step_response_model(self) -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
from arbiter.api.routes.reviews import DeliberationStepResponse
|
|
|
|
step = DeliberationStepResponse(
|
|
id="step-id",
|
|
step_type="merge",
|
|
timestamp=datetime.now(UTC),
|
|
description="Merged findings",
|
|
details={"groups": 3},
|
|
sequence=0,
|
|
)
|
|
assert step.step_type == "merge"
|
|
assert step.sequence == 0
|
|
|
|
def test_deliberation_log_response_model(self) -> None:
|
|
from arbiter.api.routes.reviews import DeliberationLogResponse
|
|
|
|
response = DeliberationLogResponse(review_id="review-123", steps=[])
|
|
assert response.review_id == "review-123"
|
|
assert response.steps == []
|
|
|
|
|
|
class TestConflictResponse:
|
|
def test_conflict_response_model(self) -> None:
|
|
from arbiter.api.routes.reviews import ConflictResponse
|
|
|
|
conflict = ConflictResponse(
|
|
id="conflict-id",
|
|
finding_ids=["finding-1", "finding-2"],
|
|
nature="trade_off",
|
|
description="Security vs simplicity",
|
|
severity_weight=0.7,
|
|
resolution="Prefer security",
|
|
winning_finding_id="finding-1",
|
|
)
|
|
assert conflict.nature == "trade_off"
|
|
assert len(conflict.finding_ids) == 2
|
|
|
|
|
|
class TestReviewListResponse:
|
|
def test_review_list_response_model(self) -> None:
|
|
from arbiter.api.routes.reviews import ReviewListResponse
|
|
|
|
response = ReviewListResponse(
|
|
items=[],
|
|
total=0,
|
|
page=1,
|
|
page_size=20,
|
|
pages=0,
|
|
)
|
|
assert response.total == 0
|
|
assert response.page == 1
|
|
|
|
|
|
class TestReviewDetail:
|
|
def test_review_detail_model(self) -> None:
|
|
from datetime import UTC, datetime
|
|
|
|
from arbiter.api.routes.reviews import ReviewDetail
|
|
|
|
detail = ReviewDetail(
|
|
id="review-id",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
pr_title="Test PR",
|
|
base_sha="abc1234",
|
|
head_sha="def5678",
|
|
author="testuser",
|
|
is_draft=False,
|
|
status="completed",
|
|
verdict="comment",
|
|
verdict_confidence=0.75,
|
|
verdict_reasoning="Found issues",
|
|
total_tokens=1500,
|
|
total_cost_usd=0.015,
|
|
tokens_by_agent={"security": 500},
|
|
cost_by_agent={"security": 0.005},
|
|
created_at=datetime.now(UTC),
|
|
started_at=datetime.now(UTC),
|
|
completed_at=datetime.now(UTC),
|
|
error_message=None,
|
|
findings=[],
|
|
conflicts=[],
|
|
)
|
|
assert detail.repository == "owner/repo"
|
|
assert detail.verdict == "comment"
|
|
|
|
|
|
class TestManualReviewResponse:
|
|
def test_manual_review_response_model(self) -> None:
|
|
from arbiter.api.routes.reviews import ManualReviewResponse
|
|
|
|
response = ManualReviewResponse(
|
|
status="queued",
|
|
job_id="job-123",
|
|
review_id=None,
|
|
message="Review queued",
|
|
)
|
|
assert response.status == "queued"
|
|
assert response.job_id == "job-123"
|
|
|
|
|
|
class TestHealthMetrics:
|
|
def test_record_review_completed(self) -> None:
|
|
# This should not raise any errors
|
|
record_review_completed(
|
|
duration_seconds=10.5,
|
|
review_status="completed",
|
|
verdict="comment",
|
|
findings_by_severity={"high": 2, "medium": 3},
|
|
tokens_in=1000,
|
|
tokens_out=500,
|
|
cost_usd=0.015,
|
|
)
|
|
|
|
def test_record_review_completed_no_verdict(self) -> None:
|
|
record_review_completed(
|
|
duration_seconds=5.0,
|
|
review_status="failed",
|
|
verdict=None,
|
|
findings_by_severity={},
|
|
tokens_in=100,
|
|
tokens_out=50,
|
|
cost_usd=0.001,
|
|
)
|
|
|
|
|
|
class TestHealthCheckModels:
|
|
def test_health_check_model(self) -> None:
|
|
from arbiter.api.routes.health import HealthCheck
|
|
|
|
check = HealthCheck(status="healthy", version="0.3.0")
|
|
assert check.status == "healthy"
|
|
assert check.version == "0.3.0"
|
|
|
|
def test_readiness_check_model(self) -> None:
|
|
from arbiter.api.routes.health import ReadinessCheck
|
|
|
|
check = ReadinessCheck(
|
|
status="ready",
|
|
components={
|
|
"database": {"status": "healthy"},
|
|
"redis": {"status": "healthy"},
|
|
},
|
|
)
|
|
assert check.status == "ready"
|
|
assert check.components["database"]["status"] == "healthy"
|
|
|
|
|
|
class TestReviewsListEndpoint:
|
|
async def test_list_reviews_empty(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/api/reviews")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["items"] == []
|
|
assert data["total"] == 0
|
|
assert data["page"] == 1
|
|
assert data["pages"] == 0
|
|
|
|
async def test_list_reviews_with_data(
|
|
self,
|
|
test_client: AsyncClient,
|
|
sample_reviews_fixture: list[ReviewModel], # noqa: ARG002
|
|
) -> None:
|
|
response = await test_client.get("/api/reviews")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 5
|
|
assert data["total"] == 5
|
|
# Should be ordered by created_at desc
|
|
assert data["items"][0]["repository"] in ["owner/repo", "other/repo"]
|
|
|
|
async def test_list_reviews_filter_by_repository(
|
|
self,
|
|
test_client: AsyncClient,
|
|
sample_reviews_fixture: list[ReviewModel], # noqa: ARG002
|
|
) -> None:
|
|
response = await test_client.get("/api/reviews?repository=owner/repo")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 3
|
|
for item in data["items"]:
|
|
assert item["repository"] == "owner/repo"
|
|
|
|
async def test_list_reviews_filter_by_status(
|
|
self,
|
|
test_client: AsyncClient,
|
|
sample_reviews_fixture: list[ReviewModel], # noqa: ARG002
|
|
) -> None:
|
|
response = await test_client.get("/api/reviews?status=completed")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 4
|
|
for item in data["items"]:
|
|
assert item["status"] == "completed"
|
|
|
|
async def test_list_reviews_filter_by_verdict(
|
|
self,
|
|
test_client: AsyncClient,
|
|
sample_reviews_fixture: list[ReviewModel], # noqa: ARG002
|
|
) -> None:
|
|
response = await test_client.get("/api/reviews?verdict=approve")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total"] == 1
|
|
assert data["items"][0]["verdict"] == "approve"
|
|
|
|
async def test_list_reviews_pagination(
|
|
self,
|
|
test_client: AsyncClient,
|
|
sample_reviews_fixture: list[ReviewModel], # noqa: ARG002
|
|
) -> None:
|
|
response = await test_client.get("/api/reviews?page=1&page_size=2")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 2
|
|
assert data["total"] == 5
|
|
assert data["page"] == 1
|
|
assert data["page_size"] == 2
|
|
assert data["pages"] == 3 # 5 items / 2 per page = 3 pages
|
|
|
|
|
|
class TestReviewDetailEndpoint:
|
|
async def test_get_review_success(
|
|
self, test_client: AsyncClient, completed_review_fixture: ReviewModel
|
|
) -> None:
|
|
response = await test_client.get(f"/api/reviews/{completed_review_fixture.id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == completed_review_fixture.id
|
|
assert data["repository"] == "owner/repo"
|
|
assert data["pr_number"] == 42
|
|
assert data["verdict"] == "comment"
|
|
|
|
async def test_get_review_not_found(self, test_client: AsyncClient) -> None:
|
|
fake_id = str(uuid4())
|
|
response = await test_client.get(f"/api/reviews/{fake_id}")
|
|
assert response.status_code == 404
|
|
assert "not found" in response.json()["detail"].lower()
|
|
|
|
async def test_get_review_includes_findings(
|
|
self, test_client: AsyncClient, completed_review_fixture: ReviewModel
|
|
) -> None:
|
|
response = await test_client.get(f"/api/reviews/{completed_review_fixture.id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["findings"]) == 2
|
|
# Check finding structure
|
|
finding = data["findings"][0]
|
|
assert "id" in finding
|
|
assert "agent" in finding
|
|
assert "severity" in finding
|
|
assert "title" in finding
|
|
|
|
|
|
class TestMetricsEndpoint:
|
|
async def test_get_metrics_empty(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/api/reviews/metrics")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_reviews"] == 0
|
|
assert data["completed_reviews"] == 0
|
|
assert data["average_cost_usd"] == 0.0
|
|
|
|
async def test_get_metrics_with_reviews(
|
|
self,
|
|
test_client: AsyncClient,
|
|
sample_reviews_fixture: list[ReviewModel], # noqa: ARG002
|
|
) -> None:
|
|
response = await test_client.get("/api/reviews/metrics")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["total_reviews"] == 5
|
|
assert data["completed_reviews"] == 4
|
|
assert data["average_cost_usd"] > 0
|
|
assert "verdict_counts" in data
|
|
assert "severity_counts" in data
|
|
|
|
|
|
class TestManualReviewEndpoint:
|
|
async def test_trigger_manual_review_missing_diff(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/api/reviews",
|
|
json={
|
|
"repository": "owner/repo",
|
|
"pr_number": 1,
|
|
"base_sha": "abc1234",
|
|
"head_sha": "def5678",
|
|
# No diff_content
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
assert "diff_content" in response.json()["detail"].lower()
|
|
|
|
async def test_trigger_manual_review_success(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.reviews.enqueue_review", return_value="job-123"):
|
|
response = await test_client.post(
|
|
"/api/reviews",
|
|
json={
|
|
"repository": "owner/repo",
|
|
"pr_number": 1,
|
|
"base_sha": "abc1234",
|
|
"head_sha": "def5678",
|
|
"diff_content": "mock diff content",
|
|
},
|
|
)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "queued"
|
|
assert data["job_id"] == "job-123"
|
|
|
|
async def test_trigger_manual_review_duplicate(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.reviews.enqueue_review", return_value=None):
|
|
response = await test_client.post(
|
|
"/api/reviews",
|
|
json={
|
|
"repository": "owner/repo",
|
|
"pr_number": 1,
|
|
"base_sha": "abc1234",
|
|
"head_sha": "def5678",
|
|
"diff_content": "mock diff content",
|
|
},
|
|
)
|
|
assert response.status_code == 202
|
|
data = response.json()
|
|
assert data["status"] == "duplicate"
|
|
|
|
|
|
class TestDeliberationLogEndpoint:
|
|
async def test_get_deliberation_log_not_found(self, test_client: AsyncClient) -> None:
|
|
fake_id = str(uuid4())
|
|
response = await test_client.get(f"/api/reviews/{fake_id}/deliberation")
|
|
assert response.status_code == 404
|
|
|
|
async def test_get_deliberation_log_success(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
# Add deliberation steps
|
|
step = DeliberationStepModel(
|
|
id=str(uuid4()),
|
|
review_id=completed_review_fixture.id,
|
|
step_type=StepType.MERGE,
|
|
timestamp=datetime.now(UTC),
|
|
description="Merged findings from all agents",
|
|
details={"groups": 2},
|
|
sequence=0,
|
|
)
|
|
db_session.add(step)
|
|
await db_session.commit()
|
|
|
|
response = await test_client.get(f"/api/reviews/{completed_review_fixture.id}/deliberation")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["review_id"] == completed_review_fixture.id
|
|
assert len(data["steps"]) == 1
|
|
assert data["steps"][0]["step_type"] == "merge"
|
|
|
|
|
|
class TestGitHubWebhookExpanded:
|
|
async def test_github_pr_opened(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review", return_value="job-123"):
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "opened",
|
|
"pull_request": {
|
|
"number": 1,
|
|
"title": "Test PR",
|
|
"base": {"sha": "abc123"},
|
|
"head": {"sha": "def456"},
|
|
"user": {"login": "testuser"},
|
|
"draft": False,
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "queued"
|
|
assert data["job_id"] == "job-123"
|
|
|
|
async def test_github_pr_synchronize(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review", return_value="job-456"):
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "synchronize",
|
|
"pull_request": {
|
|
"number": 2,
|
|
"title": "Updated PR",
|
|
"base": {"sha": "abc123"},
|
|
"head": {"sha": "newsha789"},
|
|
"user": {"login": "testuser"},
|
|
"draft": False,
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "queued"
|
|
|
|
async def test_github_pr_reopened(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review", return_value="job-789"):
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "reopened",
|
|
"pull_request": {
|
|
"number": 3,
|
|
"title": "Reopened PR",
|
|
"base": {"sha": "abc123"},
|
|
"head": {"sha": "def456"},
|
|
"user": {"login": "testuser"},
|
|
"draft": False,
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "queued"
|
|
|
|
async def test_github_pr_ignored_actions(self, test_client: AsyncClient) -> None:
|
|
for action in ["closed", "edited", "labeled", "unlabeled", "assigned"]:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": action,
|
|
"pull_request": {
|
|
"number": 1,
|
|
"title": "Test PR",
|
|
"base": {"sha": "abc123"},
|
|
"head": {"sha": "def456"},
|
|
"user": {"login": "testuser"},
|
|
"draft": False,
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
async def test_github_non_pr_events_ignored(self, test_client: AsyncClient) -> None:
|
|
for event_type in ["push", "release", "workflow_run", "issues"]:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={"action": "created"},
|
|
headers={"X-GitHub-Event": event_type},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
async def test_github_missing_fields(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "opened",
|
|
"pull_request": {
|
|
"number": 1,
|
|
# Missing base and head SHA
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
async def test_github_duplicate_review(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review", return_value=None):
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "opened",
|
|
"pull_request": {
|
|
"number": 1,
|
|
"title": "Test PR",
|
|
"base": {"sha": "abc123"},
|
|
"head": {"sha": "def456"},
|
|
"user": {"login": "testuser"},
|
|
"draft": False,
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "duplicate"
|
|
|
|
|
|
class TestGitHubCommentWebhook:
|
|
async def test_github_pr_comment_created(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_followup", return_value="followup-123"):
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "created",
|
|
"issue": {
|
|
"number": 42,
|
|
"pull_request": {"url": "..."}, # Indicates this is a PR
|
|
},
|
|
"comment": {
|
|
"id": 12345,
|
|
"body": "Why is this a security issue?",
|
|
"user": {"login": "reviewer"},
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "issue_comment"},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "queued"
|
|
|
|
async def test_github_issue_comment_ignored(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "created",
|
|
"issue": {
|
|
"number": 42,
|
|
# No pull_request key = regular issue
|
|
},
|
|
"comment": {
|
|
"id": 12345,
|
|
"body": "Some comment",
|
|
"user": {"login": "reviewer"},
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "issue_comment"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
async def test_github_own_comment_ignored(self, test_client: AsyncClient) -> None:
|
|
from arbiter.integrations import ARBITER_MARKER
|
|
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "created",
|
|
"issue": {
|
|
"number": 42,
|
|
"pull_request": {"url": "..."},
|
|
},
|
|
"comment": {
|
|
"id": 12345,
|
|
"body": f"Review results {ARBITER_MARKER}",
|
|
"user": {"login": "arbiter-bot"},
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "issue_comment"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
|
|
class TestGitLabWebhookExpanded:
|
|
async def test_gitlab_mr_open(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review", return_value="job-gl-123"):
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={
|
|
"object_kind": "merge_request",
|
|
"object_attributes": {
|
|
"action": "open",
|
|
"iid": 1,
|
|
"title": "Test MR",
|
|
"target_branch": "main",
|
|
"last_commit": {"id": "def456"},
|
|
"work_in_progress": False,
|
|
},
|
|
"project": {"path_with_namespace": "group/project"},
|
|
"user": {"username": "developer"},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "queued"
|
|
|
|
async def test_gitlab_mr_update(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_review", return_value="job-gl-456"):
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={
|
|
"object_kind": "merge_request",
|
|
"object_attributes": {
|
|
"action": "update",
|
|
"iid": 2,
|
|
"title": "Updated MR",
|
|
"target_branch": "develop",
|
|
"last_commit": {"id": "newsha789"},
|
|
"work_in_progress": False,
|
|
},
|
|
"project": {"path_with_namespace": "group/project"},
|
|
"user": {"username": "developer"},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "queued"
|
|
|
|
async def test_gitlab_mr_ignored_actions(self, test_client: AsyncClient) -> None:
|
|
for action in ["close", "merge", "approved", "unapproved"]:
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={
|
|
"object_kind": "merge_request",
|
|
"object_attributes": {
|
|
"action": action,
|
|
"iid": 1,
|
|
"title": "Test MR",
|
|
"target_branch": "main",
|
|
"last_commit": {"id": "def456"},
|
|
},
|
|
"project": {"path_with_namespace": "group/project"},
|
|
"user": {"username": "developer"},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
async def test_gitlab_non_mr_events_ignored(self, test_client: AsyncClient) -> None:
|
|
for event_type in ["push", "issue", "pipeline", "build"]:
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={"object_kind": event_type},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
|
|
class TestGitLabNoteWebhook:
|
|
async def test_gitlab_mr_note_created(self, test_client: AsyncClient) -> None:
|
|
with patch("arbiter.api.routes.webhooks.enqueue_followup", return_value="followup-gl-123"):
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={
|
|
"object_kind": "note",
|
|
"object_attributes": {
|
|
"id": 54321,
|
|
"note": "Can you explain this finding?",
|
|
"noteable_type": "MergeRequest",
|
|
},
|
|
"project": {"path_with_namespace": "group/project"},
|
|
"merge_request": {"iid": 42},
|
|
"user": {"username": "reviewer"},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "queued"
|
|
|
|
async def test_gitlab_non_mr_note_ignored(self, test_client: AsyncClient) -> None:
|
|
for noteable_type in ["Issue", "Commit", "Snippet"]:
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={
|
|
"object_kind": "note",
|
|
"object_attributes": {
|
|
"id": 54321,
|
|
"note": "Some comment",
|
|
"noteable_type": noteable_type,
|
|
},
|
|
"project": {"path_with_namespace": "group/project"},
|
|
"user": {"username": "reviewer"},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
async def test_gitlab_own_note_ignored(self, test_client: AsyncClient) -> None:
|
|
from arbiter.integrations import ARBITER_MARKER
|
|
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
json={
|
|
"object_kind": "note",
|
|
"object_attributes": {
|
|
"id": 54321,
|
|
"note": f"Review results {ARBITER_MARKER}",
|
|
"noteable_type": "MergeRequest",
|
|
},
|
|
"project": {"path_with_namespace": "group/project"},
|
|
"merge_request": {"iid": 42},
|
|
"user": {"username": "arbiter-bot"},
|
|
},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
|
|
class TestHealthEndpointsExpanded:
|
|
async def test_readiness_healthy(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/health/ready")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["status"] == "ready"
|
|
assert "database" in data["components"]
|
|
assert "redis" in data["components"]
|
|
assert data["components"]["database"]["status"] == "healthy"
|
|
|
|
async def test_prometheus_metrics_endpoint(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/metrics")
|
|
assert response.status_code == 200
|
|
# Check content type
|
|
assert "text/plain" in response.headers.get("content-type", "")
|
|
# Check for expected metric names
|
|
content = response.text
|
|
assert "arbiter_reviews_total" in content or "# HELP" in content
|
|
|
|
async def test_readiness_check_includes_worker_status(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/health/ready")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
# Worker component should be present (may show unknown if llen fails)
|
|
assert "worker" in data["components"]
|
|
|
|
|
|
class TestWebhookInvalidPayload:
|
|
async def test_github_invalid_json(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
content=b"not valid json",
|
|
headers={
|
|
"X-GitHub-Event": "pull_request",
|
|
"Content-Type": "application/json",
|
|
},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
async def test_gitlab_invalid_json(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/gitlab",
|
|
content=b"not valid json",
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
async def test_github_missing_required_fields(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "opened",
|
|
"pull_request": {
|
|
"number": 1,
|
|
"title": "Test",
|
|
# Missing base, head SHAs
|
|
},
|
|
"repository": {}, # Missing full_name
|
|
},
|
|
headers={"X-GitHub-Event": "pull_request"},
|
|
)
|
|
assert response.status_code == 400
|
|
|
|
|
|
class TestWebhookCommentActions:
|
|
async def test_github_comment_edited_ignored(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "edited",
|
|
"issue": {
|
|
"number": 42,
|
|
"pull_request": {"url": "..."},
|
|
},
|
|
"comment": {
|
|
"id": 12345,
|
|
"body": "Updated comment",
|
|
"user": {"login": "reviewer"},
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "issue_comment"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
async def test_github_comment_deleted_ignored(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.post(
|
|
"/webhooks/github",
|
|
json={
|
|
"action": "deleted",
|
|
"issue": {
|
|
"number": 42,
|
|
"pull_request": {"url": "..."},
|
|
},
|
|
"comment": {
|
|
"id": 12345,
|
|
"body": "Deleted comment",
|
|
"user": {"login": "reviewer"},
|
|
},
|
|
"repository": {"full_name": "owner/repo"},
|
|
},
|
|
headers={"X-GitHub-Event": "issue_comment"},
|
|
)
|
|
assert response.status_code == 200
|
|
assert response.json()["status"] == "ignored"
|
|
|
|
|
|
class TestAppExceptionHandlers:
|
|
async def test_value_error_returns_400(self, test_client: AsyncClient) -> None:
|
|
# The ManualReviewRequest validation can raise ValueError
|
|
response = await test_client.post(
|
|
"/api/reviews",
|
|
json={
|
|
"repository": "owner/repo",
|
|
"pr_number": 1,
|
|
"base_sha": "abc", # Too short, but this raises pydantic validation error
|
|
"head_sha": "def5678",
|
|
},
|
|
)
|
|
# Pydantic validation errors return 422, but direct ValueError returns 400
|
|
assert response.status_code in [400, 422]
|
|
|
|
|
|
class TestConversationsEndpoints:
|
|
async def test_list_conversations_empty(self, test_client: AsyncClient) -> None:
|
|
response = await test_client.get("/api/conversations")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["items"] == []
|
|
assert data["total"] == 0
|
|
|
|
async def test_list_conversations_with_data(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
# Create a conversation
|
|
conv = ConversationModel(
|
|
id=str(uuid4()),
|
|
review_id=completed_review_fixture.id,
|
|
platform="github",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
total_tokens=100,
|
|
total_cost_usd=0.001,
|
|
)
|
|
db_session.add(conv)
|
|
await db_session.commit()
|
|
|
|
response = await test_client.get("/api/conversations")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 1
|
|
assert data["items"][0]["repository"] == "owner/repo"
|
|
|
|
async def test_list_conversations_filter_by_repository(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
# Create a conversation
|
|
conv = ConversationModel(
|
|
id=str(uuid4()),
|
|
review_id=completed_review_fixture.id,
|
|
platform="github",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
total_tokens=100,
|
|
total_cost_usd=0.001,
|
|
)
|
|
db_session.add(conv)
|
|
await db_session.commit()
|
|
|
|
response = await test_client.get("/api/conversations?repository=owner/repo")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 1
|
|
|
|
response = await test_client.get("/api/conversations?repository=other/repo")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 0
|
|
|
|
async def test_list_conversations_filter_by_review_id(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
# Create a conversation
|
|
conv = ConversationModel(
|
|
id=str(uuid4()),
|
|
review_id=completed_review_fixture.id,
|
|
platform="github",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
total_tokens=100,
|
|
total_cost_usd=0.001,
|
|
)
|
|
db_session.add(conv)
|
|
await db_session.commit()
|
|
|
|
response = await test_client.get(
|
|
f"/api/conversations?review_id={completed_review_fixture.id}"
|
|
)
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 1
|
|
|
|
# Filter by non-existent review_id
|
|
fake_id = str(uuid4())
|
|
response = await test_client.get(f"/api/conversations?review_id={fake_id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert len(data["items"]) == 0
|
|
|
|
async def test_get_conversation_not_found(self, test_client: AsyncClient) -> None:
|
|
fake_id = str(uuid4())
|
|
response = await test_client.get(f"/api/conversations/{fake_id}")
|
|
assert response.status_code == 404
|
|
|
|
async def test_get_conversation_success(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
# Create a conversation with messages
|
|
conv = ConversationModel(
|
|
id=str(uuid4()),
|
|
review_id=completed_review_fixture.id,
|
|
platform="github",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
total_tokens=100,
|
|
total_cost_usd=0.001,
|
|
)
|
|
db_session.add(conv)
|
|
await db_session.flush()
|
|
|
|
msg = ConversationMessageModel(
|
|
id=str(uuid4()),
|
|
conversation_id=conv.id,
|
|
role="user",
|
|
content="Why is this a security issue?",
|
|
sequence=0,
|
|
)
|
|
db_session.add(msg)
|
|
await db_session.commit()
|
|
|
|
response = await test_client.get(f"/api/conversations/{conv.id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["id"] == conv.id
|
|
assert len(data["messages"]) == 1
|
|
|
|
async def test_get_conversation_for_review(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel,
|
|
db_session: AsyncSession,
|
|
) -> None:
|
|
# Create a conversation
|
|
conv = ConversationModel(
|
|
id=str(uuid4()),
|
|
review_id=completed_review_fixture.id,
|
|
platform="github",
|
|
repository="owner/repo",
|
|
pr_number=42,
|
|
total_tokens=100,
|
|
total_cost_usd=0.001,
|
|
)
|
|
db_session.add(conv)
|
|
await db_session.commit()
|
|
|
|
response = await test_client.get(f"/api/conversations/review/{completed_review_fixture.id}")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["review_id"] == completed_review_fixture.id
|
|
|
|
async def test_get_conversation_for_review_not_found(
|
|
self,
|
|
test_client: AsyncClient,
|
|
completed_review_fixture: ReviewModel, # noqa: ARG002
|
|
) -> None:
|
|
# Use a different review ID
|
|
fake_id = str(uuid4())
|
|
response = await test_client.get(f"/api/conversations/review/{fake_id}")
|
|
assert response.status_code == 200
|
|
assert response.json() is None
|