"""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