Files
agentci/tests/test_gitea.py
T
StanPonomarev ce9f1e3d20
Publish container image / Build and push (push) Successful in 32s
refactor tests
2026-07-26 23:49:40 +02:00

340 lines
11 KiB
Python

import json
from collections.abc import AsyncGenerator, Callable, Coroutine
from contextlib import asynccontextmanager
import httpx
import pytest
from agentci.integrations.gitea.client import Gitea, GiteaError
from agentci.integrations.gitea.models import CommentInfo, IssueInfo, PullRequestInfo
Handler = Callable[[httpx.Request], Coroutine[None, None, httpx.Response]]
@asynccontextmanager
async def gitea_client(handler: Handler, *, retries: int = 3) -> AsyncGenerator[Gitea]:
client = Gitea(
"https://gitea.example/",
"secret",
retries=retries,
transport=httpx.MockTransport(handler),
)
try:
yield client
finally:
await client.close()
@pytest.fixture
def recorded_backoffs(monkeypatch: pytest.MonkeyPatch) -> list[int]:
backoffs: list[int] = []
async def record_backoff(delay: int) -> None:
backoffs.append(delay)
monkeypatch.setattr("agentci.integrations.gitea.client.asyncio.sleep", record_backoff)
return backoffs
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,
recorded_backoffs: list[int],
) -> None:
attempts = 0
async def handler(_request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
return httpx.Response(status)
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 recorded_backoffs == []
@pytest.mark.parametrize(
("status", "failures", "expected_backoffs"),
[
(429, 1, [1]),
(500, 1, [1]),
(502, 1, [1]),
(503, 1, [1]),
(504, 1, [1]),
pytest.param(None, 2, [1, 2], id="transport"),
],
)
async def test_retryable_failure_recovers_after_exponential_backoff(
status: int | None,
failures: int,
expected_backoffs: list[int],
recorded_backoffs: list[int],
) -> None:
attempts = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if attempts <= failures:
if status is None:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(status)
return httpx.Response(200, json={"default_branch": "main"})
async with gitea_client(handler) as client:
assert await client.default_branch("org", "repo") == "main"
assert attempts == failures + 1
assert recorded_backoffs == expected_backoffs
@pytest.mark.parametrize(
("status", "retries", "expected_message", "expected_cause", "expected_backoffs"),
[
pytest.param(
503,
3,
"Gitea remained unavailable for GET /repos/org/repo",
None,
[1, 2],
id="status",
),
pytest.param(
None,
2,
"Gitea request failed: GET /repos/org/repo",
httpx.ConnectError,
[1],
id="transport",
),
],
)
async def test_retry_exhaustion_reports_failure(
status: int | None,
retries: int,
expected_message: str,
expected_cause: type[BaseException] | None,
expected_backoffs: list[int],
recorded_backoffs: list[int],
) -> None:
attempts = 0
async def handler(request: httpx.Request) -> httpx.Response:
nonlocal attempts
attempts += 1
if status is None:
raise httpx.ConnectError("connection refused", request=request)
return httpx.Response(status)
async with gitea_client(handler, retries=retries) as client:
with pytest.raises(GiteaError, match=expected_message) as raised:
await client.default_branch("org", "repo")
if expected_cause is None:
assert raised.value.__cause__ is None
else:
assert isinstance(raised.value.__cause__, expected_cause)
assert attempts == retries
assert recorded_backoffs == expected_backoffs