test(git): httpx-mock coverage for list_open_prs + get_pr_diff

Covers the inbound-PR read surface: list_open_prs normalization + fork/internal
classification (and the recent _fetch_open_prs/_normalize_open_pr refactor),
plus get_pr_diff's diff-media-type request — both with their safe-empty paths
on missing token / GitHub error. The DB-backed paths
(ingest/complete_review/pr_review_claim/supersede umbrella) are covered
separately.
This commit is contained in:
Renn F
2026-06-17 16:50:10 +02:00
parent 66a8ad40eb
commit 748ff7813e
+160
View File
@@ -0,0 +1,160 @@
"""GitService inbound-PR reads — list_open_prs + get_pr_diff (httpx fully mocked).
``list_open_prs`` is the discovery surface for the inbound-PR reviewer: it lists
ALL open PRs and normalizes each (number, url, title, head_ref, head_sha,
is_fork, user_login, author_association). ``get_pr_diff`` fetches a PR's unified
diff read-only. Both return safe empties ([] / "") on a missing token or any
GitHub error so the poll/claim never crash.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import GitService
_PR = 7
def _service() -> GitService:
session = MagicMock()
session.execute = AsyncMock()
svc = GitService(session)
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value="tok"))
return svc
def _resp(status_code: int, *, json_payload: Any = None, text: str = "") -> MagicMock:
resp = MagicMock()
resp.status_code = status_code
resp.is_success = 200 <= status_code < 300 # noqa: PLR2004
resp.json.return_value = json_payload
resp.text = text
return resp
def _client(get_resp: MagicMock) -> MagicMock:
client = MagicMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
client.get = AsyncMock(return_value=get_resp)
return client
def _patch_project() -> Any:
fake = MagicMock()
fake.get_by_slug = AsyncMock(
return_value=MagicMock(git_url="https://github.com/acme/repo.git")
)
return patch("roboco.services.git.get_project_service", return_value=fake)
def _pr(*, number: int, head_full: str, login: str, assoc: str) -> dict[str, Any]:
return {
"number": number,
"html_url": f"https://github.com/acme/repo/pull/{number}",
"title": f"PR {number}",
"head": {
"ref": "feature-x",
"sha": "deadbeef",
"repo": {"full_name": head_full},
},
"user": {"login": login},
"author_association": assoc,
}
# ---------------------------------------------------------------------------
# list_open_prs
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_open_prs_normalizes_and_flags_fork() -> None:
svc = _service()
payload = [
_pr(number=7, head_full="forkowner/repo", login="corey", assoc="CONTRIBUTOR"),
_pr(number=8, head_full="acme/repo", login="be-dev-1", assoc="MEMBER"),
]
client = _client(_resp(200, json_payload=payload))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.list_open_prs("roboco")
assert len(out) == 2 # noqa: PLR2004
fork, internal = out
assert fork == {
"number": 7,
"url": "https://github.com/acme/repo/pull/7",
"title": "PR 7",
"head_ref": "feature-x",
"head_sha": "deadbeef",
"is_fork": True,
"user_login": "corey",
"author_association": "CONTRIBUTOR",
}
# Same-repo head → not a fork.
assert internal["is_fork"] is False
assert internal["author_association"] == "MEMBER"
@pytest.mark.asyncio
async def test_list_open_prs_empty_on_missing_token() -> None:
svc = _service()
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
with _patch_project():
assert await svc.list_open_prs("roboco") == []
@pytest.mark.asyncio
async def test_list_open_prs_empty_on_github_error() -> None:
svc = _service()
client = _client(_resp(403, text="forbidden"))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
assert await svc.list_open_prs("roboco") == []
# ---------------------------------------------------------------------------
# get_pr_diff
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_get_pr_diff_returns_diff_text() -> None:
svc = _service()
diff = "diff --git a/x b/x\n+added line\n"
client = _client(_resp(200, text=diff))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
out = await svc.get_pr_diff("roboco", _PR)
assert out == diff
call = client.get.await_args
assert f"/pulls/{_PR}" in call.args[0]
assert call.kwargs["headers"]["Accept"] == "application/vnd.github.v3.diff"
@pytest.mark.asyncio
async def test_get_pr_diff_empty_on_missing_token() -> None:
svc = _service()
object.__setattr__(svc, "_token_for_project", AsyncMock(return_value=None))
with _patch_project():
assert await svc.get_pr_diff("roboco", _PR) == ""
@pytest.mark.asyncio
async def test_get_pr_diff_empty_on_non_2xx() -> None:
svc = _service()
client = _client(_resp(404, text="not found"))
with (
_patch_project(),
patch("roboco.services.git.httpx.AsyncClient", return_value=client),
):
assert await svc.get_pr_diff("roboco", _PR) == ""