475 lines
18 KiB
Python
475 lines
18 KiB
Python
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/<unsafe>", "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/<unsafe>", "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()
|
|
|