Files
agentci/tests/test_gitea.py
T
2026-07-22 23:10:23 +02:00

345 lines
12 KiB
Python

import json
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import asynccontextmanager
import httpx
import pytest
from agentci.gitea import CommentInfo, Gitea, GiteaError, IssueInfo, PullRequestInfo
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
@asynccontextmanager
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncIterator[Gitea]:
client = Gitea(
"https://gitea.example/",
"secret",
retries=retries,
transport=httpx.MockTransport(handler),
)
try:
yield client
finally:
await client.close()
async def test_sends_authenticated_json_request_contract() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json={"id": 17})
async with gitea_client(handler) as client:
assert await client.update_comment("org", "repo", 17, "updated status")
assert len(requests) == 1
request = requests[0]
assert request.method == "PATCH"
assert request.url == httpx.URL(
"https://gitea.example/api/v1/repos/org/repo/issues/comments/17"
)
assert request.headers["authorization"] == "token secret"
assert request.headers["accept"] == "application/json"
assert request.headers["content-type"] == "application/json"
assert json.loads(request.content) == {"body": "updated status"}
@pytest.mark.parametrize(
("permission", "expected"),
[("write", True), ("ADMIN", True), ("owner", True), ("read", False), (None, False)],
)
async def test_maps_repository_permissions(permission: str | None, expected: bool) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/org/repo/collaborators/alice/permission"
return httpx.Response(200, json={"permission": permission})
async with gitea_client(handler) as client:
assert await client.has_write_permission("org", "repo", "alice") is expected
async def test_maps_issue_and_pull_request_responses() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
match request.url.path:
case "/api/v1/repos/org/repo":
return httpx.Response(200, json={"default_branch": "trunk"})
case "/api/v1/repos/org/repo/issues/12":
return httpx.Response(
200,
json={"title": "Issue title", "body": None, "state": "open"},
)
case "/api/v1/repos/org/repo/pulls/8":
return httpx.Response(
200,
json={
"title": "Pull title",
"body": None,
"state": "open",
"merged": False,
"base": {"ref": "trunk"},
"head": {
"ref": "feature",
"sha": "abc123",
"repo": {"owner": {"login": "fork-owner"}, "name": "fork"},
},
},
)
raise AssertionError(f"unexpected request: {request.url}")
async with gitea_client(handler) as client:
branch = await client.default_branch("org", "repo")
issue = await client.issue("org", "repo", 12)
pull = await client.pull_request("org", "repo", 8)
assert branch == "trunk"
assert issue == IssueInfo(number=12, title="Issue title", body="", state="open")
assert pull == PullRequestInfo(
number=8,
title="Pull title",
body="",
state="open",
merged=False,
base_branch="trunk",
head_branch="feature",
head_sha="abc123",
head_owner="fork-owner",
head_repo="fork",
)
assert pull.is_open
async def test_creates_comment_and_pull_request_with_expected_payloads() -> None:
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
if request.url.path.endswith("/issues/4/comments"):
return httpx.Response(201, json={"id": "23"})
if request.method == "POST" and request.url.path.endswith("/pulls"):
return httpx.Response(201, json={"number": 9})
if request.method == "GET" and request.url.path.endswith("/pulls/9"):
return httpx.Response(
200,
json={
"title": "Implement it",
"body": "Details",
"state": "open",
"merged": False,
"base": {"ref": "main"},
"head": {
"ref": "agent/work",
"sha": "def456",
"repo": {"owner": {"login": "org"}, "name": "repo"},
},
},
)
raise AssertionError(f"unexpected request: {request.method} {request.url}")
async with gitea_client(handler) as client:
comment_id = await client.create_comment("org", "repo", 4, "Working")
pull = await client.create_pull_request(
"org",
"repo",
title="Implement it",
body="Details",
head="agent/work",
base="main",
)
assert comment_id == 23
assert pull.number == 9
assert [(request.method, request.url.path) for request in requests] == [
("POST", "/api/v1/repos/org/repo/issues/4/comments"),
("POST", "/api/v1/repos/org/repo/pulls"),
("GET", "/api/v1/repos/org/repo/pulls/9"),
]
assert json.loads(requests[0].content) == {"body": "Working"}
assert json.loads(requests[1].content) == {
"title": "Implement it",
"body": "Details",
"head": "agent/work",
"base": "main",
}
async def test_maps_allowed_not_found_responses_without_retry() -> None:
paths: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
paths.append(request.url.path)
return httpx.Response(404)
async with gitea_client(handler) as client:
updated = await client.update_comment("org", "repo", 99, "missing")
comments = await client.review_comments("org", "repo", 7, 3)
assert not updated
assert comments == []
assert paths == [
"/api/v1/repos/org/repo/issues/comments/99",
"/api/v1/repos/org/repo/pulls/7/reviews/3/comments",
]
@pytest.mark.parametrize(("first_page_size", "expected_pages"), [(0, [1]), (49, [1]), (50, [1, 2])])
async def test_pagination_stops_only_after_a_short_page(
first_page_size: int, expected_pages: list[int]
) -> None:
pages: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/repos/org/repo/issues/6/comments"
assert request.url.params["limit"] == "50"
page = int(request.url.params["page"])
pages.append(page)
size = first_page_size if page == 1 else 1
offset = 0 if page == 1 else 50
return httpx.Response(
200,
json=[
{
"id": offset + index + 1,
"user": {"login": f"user-{offset + index + 1}"},
"body": None,
"created_at": None,
}
for index in range(size)
],
)
async with gitea_client(handler) as client:
comments = await client.issue_comments("org", "repo", 6)
expected_count = first_page_size + (1 if first_page_size == 50 else 0)
assert pages == expected_pages
assert len(comments) == expected_count
if comments:
assert comments[0] == CommentInfo(id=1, author="user-1", body="", created_at="")
assert comments[-1].id == expected_count
@pytest.mark.parametrize("status", [400, 401, 403, 404, 422])
async def test_nonretryable_status_fails_once(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(status)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
with pytest.raises(
GiteaError,
match=rf"Gitea returned {status} for GET /repos/org/repo",
):
await client.default_branch("org", "repo")
assert attempts == 1
assert sleeps == []
@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
async def test_retryable_status_recovers_after_backoff(
status: int, monkeypatch: pytest.MonkeyPatch
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts == 1:
return httpx.Response(status)
return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == 2
assert sleeps == [1]
async def test_retryable_status_exhaustion_uses_exponential_backoff(
monkeypatch: pytest.MonkeyPatch,
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(503)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
with pytest.raises(
GiteaError,
match="Gitea remained unavailable for GET /repos/org/repo",
):
await client.default_branch("org", "repo")
assert attempts == 3
assert sleeps == [1, 2]
async def test_transport_failure_retries_and_recovers(monkeypatch: pytest.MonkeyPatch) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts < 3:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(200, json={"default_branch": "main"})
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == 3
assert sleeps == [1, 2]
async def test_transport_failure_exhaustion_preserves_cause(
monkeypatch: pytest.MonkeyPatch,
) -> None:
attempts = 0
sleeps: list[int] = []
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
raise httpx.ConnectError("connection refused", request=request)
async def sleep(delay: int) -> None:
sleeps.append(delay)
monkeypatch.setattr("agentci.gitea.asyncio.sleep", sleep)
async with gitea_client(handler, retries=2) as client:
with pytest.raises(
GiteaError,
match="Gitea request failed: GET /repos/org/repo",
) as raised:
await client.default_branch("org", "repo")
assert isinstance(raised.value.__cause__, httpx.ConnectError)
assert attempts == 2
assert sleeps == [1]