From 748ff7813effce5fe44e6d13bb4b8734ad943feb Mon Sep 17 00:00:00 2001 From: Renn F Date: Wed, 17 Jun 2026 15:22:21 +0200 Subject: [PATCH] test(git): httpx-mock coverage for list_open_prs + get_pr_diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/unit/services/test_git_inbound_pr.py | 160 +++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 tests/unit/services/test_git_inbound_pr.py diff --git a/tests/unit/services/test_git_inbound_pr.py b/tests/unit/services/test_git_inbound_pr.py new file mode 100644 index 00000000..ebc7b3ac --- /dev/null +++ b/tests/unit/services/test_git_inbound_pr.py @@ -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) == ""