"""Unit tests for agent_team.transport.github_adapter (§3.3.1, §7.1 P4). Fully hermetic: the HTTP transport is dependency-injected with an in-memory fake, so no network call, token, or live infrastructure is exercised. """ from __future__ import annotations import json from typing import Any import pytest from agent_team.transport.base import ( GITHUB_MARKER_TEMPLATE, QuestionSet, Transport, ) from agent_team.transport.github_adapter import ( GITHUB_API_ROOT, GitHubApiError, GitHubTransport, build_marker, extract_question_id, render_question_comment, ) # --------------------------------------------------------------------------- # # Fakes / fixtures # --------------------------------------------------------------------------- # class FakeHttpPost: """In-memory ``HttpPost`` double recording calls and returning a scripted ``(status, data)``.""" def __init__(self, status: int = 201, data: dict[str, Any] | None = None) -> None: self.status = status self.data = {"id": 987654321} if data is None else data self.calls: list[dict[str, Any]] = [] def __call__( self, url: str, *, headers: dict[str, str], json_body: dict[str, Any], ) -> tuple[int, dict[str, Any]]: self.calls.append({"url": url, "headers": headers, "json_body": json_body}) return self.status, self.data def make_transport( http_post: FakeHttpPost | None = None, *, token: str | None = "ghp_fake", ) -> GitHubTransport: return GitHubTransport( owner="Sea-Haven-Industries", repo="agent-team", issue_number=42, http_post=http_post or FakeHttpPost(), token_provider=(lambda: token), ) def make_question_set() -> QuestionSet: return QuestionSet( thread_id="thread-1", question_id="qid-abc", turn=3, questions=["Proceed with the migration?", "Which region?"], context={"repo": "Sea-Haven-Industries/agent-team", "summary": "DB cutover"}, ) # --------------------------------------------------------------------------- # # Contract / typing # --------------------------------------------------------------------------- # def test_is_transport_subclass() -> None: assert issubclass(GitHubTransport, Transport) def test_instantiable_concrete_adapter() -> None: # Transport is abstract; the leaf must implement both abstract methods. transport = make_transport() assert isinstance(transport, Transport) # --------------------------------------------------------------------------- # # Marker helpers # --------------------------------------------------------------------------- # def test_build_marker_uses_foundation_template() -> None: assert build_marker("xyz") == GITHUB_MARKER_TEMPLATE.format(question_id="xyz") assert build_marker("xyz") == "" def test_extract_question_id_roundtrips_build_marker() -> None: qid = "deadbeef1234" assert extract_question_id(build_marker(qid)) == qid def test_extract_question_id_found_in_quoted_reply() -> None: quoted = "> \n> ### Agent-team needs input\n\nYes, go ahead." assert extract_question_id(quoted) == "q-77" def test_extract_question_id_absent_returns_none() -> None: assert extract_question_id("just a normal comment") is None assert extract_question_id("") is None # --------------------------------------------------------------------------- # # Rendering # --------------------------------------------------------------------------- # def test_render_embeds_marker_and_questions() -> None: qs = make_question_set() body = render_question_comment( qs, question_id="qid-abc", turn=3, deadline="2026-06-18T00:00:00Z" ) assert "" in body assert extract_question_id(body) == "qid-abc" assert "1. Proceed with the migration?" in body assert "2. Which region?" in body assert "turn 3" in body assert "2026-06-18T00:00:00Z" in body # Context surfaced. assert "Sea-Haven-Industries/agent-team" in body assert "DB cutover" in body def test_render_handles_empty_questions() -> None: qs = QuestionSet(thread_id="t", question_id="q", turn=0, questions=[]) body = render_question_comment( qs, question_id="q", turn=0, deadline="2026-06-18T00:00:00Z" ) assert "(no questions)" in body assert extract_question_id(body) == "q" # --------------------------------------------------------------------------- # # post_question # --------------------------------------------------------------------------- # def test_post_question_returns_comment_id_as_channel_ref() -> None: http = FakeHttpPost(status=201, data={"id": 555}) transport = make_transport(http) ref = transport.post_question( thread_id="thread-1", question_id="qid-abc", turn=3, question_set=make_question_set(), deadline="2026-06-18T00:00:00Z", ) assert ref == "555" assert isinstance(ref, str) def test_post_question_targets_correct_issue_endpoint() -> None: http = FakeHttpPost() transport = make_transport(http) transport.post_question( thread_id="thread-1", question_id="qid-abc", turn=1, question_set=make_question_set(), deadline="2026-06-18T00:00:00Z", ) (call,) = http.calls assert call["url"] == ( f"{GITHUB_API_ROOT}/repos/Sea-Haven-Industries/agent-team/issues/42/comments" ) def test_post_question_body_embeds_question_id() -> None: http = FakeHttpPost() transport = make_transport(http) transport.post_question( thread_id="thread-1", question_id="qid-abc", turn=1, question_set=make_question_set(), deadline="2026-06-18T00:00:00Z", ) body = http.calls[0]["json_body"]["body"] assert extract_question_id(body) == "qid-abc" def test_post_question_sends_bearer_auth_header() -> None: http = FakeHttpPost() transport = make_transport(http, token="ghp_secret_value") transport.post_question( thread_id="t", question_id="q", turn=0, question_set=make_question_set(), deadline="d", ) headers = http.calls[0]["headers"] assert headers["Authorization"] == "Bearer ghp_secret_value" assert headers["Accept"] == "application/vnd.github+json" assert headers["X-GitHub-Api-Version"] == "2022-11-28" def test_post_question_raises_on_missing_token() -> None: transport = make_transport(FakeHttpPost(), token=None) with pytest.raises(GitHubApiError) as exc: transport.post_question( thread_id="t", question_id="q", turn=0, question_set=make_question_set(), deadline="d", ) assert exc.value.status == 401 def test_post_question_reads_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None: http = FakeHttpPost() monkeypatch.setenv("GITHUB_TOKEN", "ghp_from_env") transport = GitHubTransport(owner="o", repo="r", issue_number=1, http_post=http) transport.post_question( thread_id="t", question_id="q", turn=0, question_set=make_question_set(), deadline="d", ) assert http.calls[0]["headers"]["Authorization"] == "Bearer ghp_from_env" def test_post_question_raises_on_non_2xx() -> None: http = FakeHttpPost(status=422, data={"message": "Validation Failed"}) transport = make_transport(http) with pytest.raises(GitHubApiError) as exc: transport.post_question( thread_id="t", question_id="q", turn=0, question_set=make_question_set(), deadline="d", ) assert exc.value.status == 422 assert "Validation Failed" in exc.value.body def test_post_question_raises_when_response_missing_id() -> None: http = FakeHttpPost(status=201, data={"no_id": True}) transport = make_transport(http) with pytest.raises(GitHubApiError): transport.post_question( thread_id="t", question_id="q", turn=0, question_set=make_question_set(), deadline="d", ) def test_comments_url_respects_custom_api_root() -> None: transport = GitHubTransport( owner="o", repo="r", issue_number=7, http_post=FakeHttpPost(), api_root="https://ghe.example.com/api/v3/", token_provider=lambda: "t", ) assert transport.comments_url == ( "https://ghe.example.com/api/v3/repos/o/r/issues/7/comments" ) # --------------------------------------------------------------------------- # # parse_answer # --------------------------------------------------------------------------- # def test_parse_answer_full_webhook_shape() -> None: transport = make_transport() raw = { "comment": { "body": "\nYes, proceed with us-east-1.", "user": {"login": "amoussa1229"}, } } qid, answer, via = transport.parse_answer(raw) assert qid == "qid-abc" assert answer == "Yes, proceed with us-east-1." assert via == "github:amoussa1229" def test_parse_answer_flattened_shape() -> None: transport = make_transport() raw = { "body": " looks good", "user": {"login": "adam"}, } qid, answer, via = transport.parse_answer(raw) assert qid == "q9" assert answer == "looks good" assert via == "github:adam" def test_parse_answer_strips_quoted_marker_lines() -> None: transport = make_transport() raw = { "comment": { "body": ( "> \n" "> ### Agent-team needs input (turn 1)\n" "\n" "Approved. Use the staging bucket." ), "user": {"login": "adam"}, } } qid, answer, via = transport.parse_answer(raw) assert qid == "q-77" assert "\nok"}} ) assert qid == "q1" assert via == "github" # --------------------------------------------------------------------------- # # Round-trip: post then parse the reply maps to the same question_id # --------------------------------------------------------------------------- # def test_post_then_answer_roundtrip_question_id() -> None: http = FakeHttpPost() transport = make_transport(http) transport.post_question( thread_id="thread-1", question_id="round-trip-qid", turn=2, question_set=make_question_set(), deadline="2026-06-18T00:00:00Z", ) posted_body = http.calls[0]["json_body"]["body"] # Simulate a human quoting the posted comment in their reply. reply_body = "\n".join(f"> {line}" for line in posted_body.splitlines()) reply_body += "\n\nYes." qid, answer, via = transport.parse_answer( {"comment": {"body": reply_body, "user": {"login": "adam"}}} ) assert qid == "round-trip-qid" assert answer == "Yes." def test_default_http_post_is_not_called_in_tests() -> None: # Sanity: the adapter never falls back to the network when a poster is # injected (guards against an accidental live call in CI). http = FakeHttpPost() transport = make_transport(http) transport.post_question( thread_id="t", question_id="q", turn=0, question_set=make_question_set(), deadline="d", ) assert len(http.calls) == 1 def test_api_error_str_is_truncated_and_typed() -> None: err = GitHubApiError(500, "x" * 1000) assert err.status == 500 assert isinstance(err, RuntimeError) # __str__ truncates the body to keep logs bounded. assert len(str(err)) < 300 assert json.dumps({"ok": True}) # keep json import meaningful/no-op