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_requested_review_checks_endpoint_authorizes_and_returns_status_only(monkeypatch): calls = [] async def requested(repository, number): calls.append(("requested", repository, number)) return True async def checks(repository, number): calls.append(("checks", repository, number)) return {"head_sha": "def456", "ci_state": "success", "checks": []} monkeypatch.setattr(main, "is_requested_review", requested) monkeypatch.setattr(main.gitea_proxy, "pull_check_status", checks) 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/checks") assert response.status_code == 200 assert response.headers["cache-control"] == "no-store" assert response.json() == {"head_sha": "def456", "ci_state": "success", "checks": []} assert calls == [ ("requested", "stackchain/api", 7), ("checks", "stackchain/api", 7), ] @pytest.mark.anyio async def test_gitea_review_detail_returns_bounded_actionable_checks(monkeypatch): monkeypatch.setattr(gitea_proxy, "GITEA_URL", "https://forge.example/git") async def handler(request): path = request.url.path if path.endswith("/pulls/7"): return httpx.Response(200, json={ "title": "Review API", "head": {"sha": "abc123"}, "user": {"login": "alex"}, }) if path.endswith("/pulls/7/files") or path.endswith("/pulls/7/reviews"): return httpx.Response(200, json=[]) if path.endswith("/commits/abc123/status"): return httpx.Response(200, json={ "state": "failure", "statuses": [ { "context": "lint/", "status": "failure", "description": "Formatting failed " + "x" * 300, "target_url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4", }, { "context": "external", "status": "pending", "description": "Waiting", "target_url": "https://evil.example/job/1", }, {"context": "build-release", "status": "success", "description": "Passed"}, ] + [{"context": f"extra-{index}", "status": "success"} for index in range(30)], }) if path.endswith("/pulls/7.diff"): return httpx.Response(200, text="") raise AssertionError(f"unexpected request: {request.method} {path}") gitea_proxy.start_client(transport=httpx.MockTransport(handler)) try: detail = await gitea_proxy.pull_review_detail("stackchain/api", 7) finally: await gitea_proxy.stop_client() assert len(detail["checks"]) == 20 assert detail["checks"][:3] == [ { "name": "lint/", "state": "failure", "description": "Formatting failed " + "x" * 222, "url": "https://forge.example/git/stackchain/api/actions/runs/9/jobs/4", }, {"name": "external", "state": "pending", "description": "Waiting", "url": ""}, {"name": "build-release", "state": "success", "description": "Passed", "url": ""}, ] @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()