import asyncio import httpx import pytest from src import gitea_proxy, main @pytest.mark.anyio async def test_review_detail_endpoint_returns_normalized_no_store_payload(monkeypatch): async def requested(repository, number): return (repository, number) == ("stackchain/api", 7) async def detail(repository, number): assert (repository, number) == ("stackchain/api", 7) return { "repository": repository, "number": number, "title": "Review API", "body": "Check retries", "url": "https://forge.example/stackchain/api/pulls/7", "author": "alex", "ci_state": "success", "files": [{"filename": "src/api.py", "status": "modified", "additions": 8, "deletions": 2}], "reviews": [{"user": {"login": "sam"}, "state": "APPROVED", "body": "Good"}], } monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main, "pull_review_detail", detail) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review") assert response.status_code == 200 assert response.headers["cache-control"] == "no-store" assert response.json()["files"][0]["filename"] == "src/api.py" @pytest.mark.anyio async def test_review_detail_rejects_pulls_not_requested_from_service_user(monkeypatch): async def requested(repository, number): return False monkeypatch.setattr(main, "is_requested_review", requested) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/repos/private/secret/pulls/9/review") assert response.status_code == 404 assert response.headers["cache-control"] == "no-store" @pytest.mark.anyio async def test_review_detail_has_one_retryable_deadline_and_cancels_pending_work(monkeypatch): cancelled = asyncio.Event() async def requested(repository, number): try: await asyncio.Event().wait() finally: cancelled.set() monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.get("/api/v1/repos/stackchain/api/pulls/7/review") assert response.status_code == 503 assert response.headers["cache-control"] == "no-store" assert response.headers["retry-after"] == "1" assert response.json() == { "error": "Pull request review details timed out. Please retry." } assert cancelled.is_set() @pytest.mark.anyio async def test_review_submission_posts_validated_decision_for_requested_current_head(monkeypatch): calls = [] async def requested(repository, number): return (repository, number) == ("stackchain/api", 7) async def submit(repository, number, expected_head_sha, decision, body): calls.append((repository, number, expected_head_sha, decision, body)) return {"id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91"} monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit, raising=False) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "approve", "body": "Looks good on mobile.", "expected_head_sha": "abc123", }, ) assert response.status_code == 201 assert response.headers["cache-control"] == "no-store" assert response.json() == { "id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91", } assert calls == [ ("stackchain/api", 7, "abc123", "approve", "Looks good on mobile.") ] @pytest.mark.anyio async def test_review_submission_forwards_valid_inline_comments(monkeypatch): calls = [] async def requested(repository, number): return True async def submit(repository, number, expected_head_sha, decision, body, comments): calls.append((repository, number, expected_head_sha, decision, body, comments)) return {"id": 92, "state": "REQUEST_CHANGES", "url": "https://forge.example/reviews/92"} monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "request_changes", "body": "One inline blocker.", "expected_head_sha": "abc123", "comments": [{ "path": "src/api.py", "body": "Handle the empty value.", "new_position": 42, }], }, ) assert response.status_code == 201 assert calls == [( "stackchain/api", 7, "abc123", "request_changes", "One inline blocker.", [{"path": "src/api.py", "body": "Handle the empty value.", "new_position": 42, "old_position": None}], )] @pytest.mark.anyio async def test_gitea_review_submission_checks_head_then_maps_decision_upstream(): requests = [] async def handler(request): requests.append(request) if request.method == "GET": return httpx.Response(200, json={"head": {"sha": "abc123"}}) return httpx.Response( 200, json={ "id": 91, "state": "APPROVED", "html_url": "https://forge.example/reviews/91", }, ) gitea_proxy.start_client(transport=httpx.MockTransport(handler)) try: result = await gitea_proxy.submit_pull_review( "stackchain/api", 7, "abc123", "approve", "Looks good." ) finally: await gitea_proxy.stop_client() assert [(request.method, request.url.path) for request in requests] == [ ("GET", "/api/v1/repos/stackchain/api/pulls/7"), ("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"), ] assert requests[1].content == b'{"body":"Looks good.","event":"APPROVE","commit_id":"abc123"}' assert result == { "id": 91, "state": "APPROVED", "url": "https://forge.example/reviews/91", } @pytest.mark.anyio async def test_gitea_review_submission_sends_inline_comments_in_single_review_request(): requests = [] async def handler(request): requests.append(request) if request.url.path.endswith("/files"): return httpx.Response(200, json=[{"filename": "src/api.py"}]) if request.method == "GET": return httpx.Response(200, json={"head": {"sha": "abc123"}}) return httpx.Response(200, json={"id": 93, "state": "COMMENT"}) gitea_proxy.start_client(transport=httpx.MockTransport(handler)) try: await gitea_proxy.submit_pull_review( "stackchain/api", 7, "abc123", "comment", "Summary", [{"path": "src/api.py", "body": "Handle empty values.", "new_position": 42, "old_position": None}], ) finally: await gitea_proxy.stop_client() assert [(request.method, request.url.path) for request in requests] == [ ("GET", "/api/v1/repos/stackchain/api/pulls/7"), ("GET", "/api/v1/repos/stackchain/api/pulls/7/files"), ("POST", "/api/v1/repos/stackchain/api/pulls/7/reviews"), ] assert requests[-1].content == ( b'{"body":"Summary","event":"COMMENT","commit_id":"abc123","comments":' b'[{"path":"src/api.py","body":"Handle empty values.","new_position":42,"old_position":null}]}' ) @pytest.mark.anyio async def test_gitea_review_submission_rejects_changed_head_without_posting(): methods = [] async def handler(request): methods.append(request.method) return httpx.Response(200, json={"head": {"sha": "new456"}}) gitea_proxy.start_client(transport=httpx.MockTransport(handler)) try: with pytest.raises(gitea_proxy.StaleReviewError): await gitea_proxy.submit_pull_review( "stackchain/api", 7, "abc123", "request_changes", "Please revise." ) finally: await gitea_proxy.stop_client() assert methods == ["GET"] @pytest.mark.anyio async def test_gitea_review_submission_rejects_inline_comment_for_unchanged_path_without_posting(): requests = [] async def handler(request): requests.append((request.method, request.url.path)) if request.url.path.endswith("/files"): return httpx.Response(200, json=[{"filename": "src/api.py"}]) return httpx.Response(200, json={"head": {"sha": "abc123"}}) gitea_proxy.start_client(transport=httpx.MockTransport(handler)) try: with pytest.raises(gitea_proxy.InvalidReviewCommentError): await gitea_proxy.submit_pull_review( "stackchain/api", 7, "abc123", "comment", "Review note.", [{"path": "src/unknown.py", "body": "Not in this change", "new_position": 4}], ) finally: await gitea_proxy.stop_client() assert requests == [ ("GET", "/api/v1/repos/stackchain/api/pulls/7"), ("GET", "/api/v1/repos/stackchain/api/pulls/7/files"), ] @pytest.mark.anyio async def test_review_submission_returns_conflict_when_pull_head_changed(monkeypatch): async def requested(repository, number): return True async def submit(*args): raise gitea_proxy.StaleReviewError("changed") monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "request_changes", "body": "Please revise.", "expected_head_sha": "abc123", }, ) assert response.status_code == 409 assert response.json() == { "error": "New commits were pushed. Refresh the review before submitting." } @pytest.mark.anyio async def test_review_submission_returns_validation_error_when_inline_path_is_stale(monkeypatch): async def requested(repository, number): return True async def submit(*args): raise gitea_proxy.InvalidReviewCommentError("unknown path") monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main.gitea_proxy, "submit_pull_review", submit) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "comment", "body": "Note", "expected_head_sha": "abc123", "comments": [{"path": "gone.py", "body": "Stale", "new_position": 1}], }, ) assert response.status_code == 422 assert response.json() == { "error": "An inline comment no longer matches this pull request. Refresh the review." } @pytest.mark.anyio async def test_review_submission_rejects_unsupported_decision_before_upstream(monkeypatch): called = False async def requested(repository, number): nonlocal called called = True return True monkeypatch.setattr(main, "is_requested_review", requested) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "merge", "body": "Ship it.", "expected_head_sha": "abc123", }, ) assert response.status_code == 422 assert called is False @pytest.mark.anyio async def test_review_submission_rejects_oversized_feedback_before_upstream(monkeypatch): called = False async def requested(repository, number): nonlocal called called = True return True monkeypatch.setattr(main, "is_requested_review", requested) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "comment", "body": "x" * 10_001, "expected_head_sha": "abc123", }, ) assert response.status_code == 422 assert called is False @pytest.mark.anyio async def test_review_submission_deadline_cancels_request_check(monkeypatch): cancelled = asyncio.Event() async def requested(repository, number): try: await asyncio.Event().wait() finally: cancelled.set() monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main, "REVIEW_DETAIL_TIMEOUT_SECONDS", 0.01) transport = httpx.ASGITransport(app=main.app) async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: response = await asyncio.wait_for( client.post( "/api/v1/repos/stackchain/api/pulls/7/review", json={ "decision": "comment", "body": "Review note.", "expected_head_sha": "abc123", }, ), timeout=0.2, ) assert response.status_code == 503 assert response.headers["retry-after"] == "1" assert cancelled.is_set()