"""Tests for GiteaIssueTracker — the outbound adapter for the IssueTracker port. Strategy: use httpx.MockTransport to intercept HTTP calls without a live Gitea server. This exercises the real adapter code (headers, URL construction, JSON serialisation, error handling) without any external network dependency. We import GiteaIssueTracker from adapters.outbound.gitea_issues and verify it against the IssueTracker ABC from domain.ports — confirming the adapter truly satisfies the port contract. """ import json import pytest import httpx from domain.ports import IssueTracker from adapters.outbound.gitea_issues import GiteaIssueTracker # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_issue_response( issue_number: int = 1, title: str = "Test issue", html_url: str = "https://gitea.example.com/owner/repo/issues/1", ) -> dict: """Return a minimal Gitea issue API response payload.""" return { "id": issue_number, "number": issue_number, "title": title, "html_url": html_url, "state": "open", } class _MockTransport(httpx.AsyncBaseTransport): """Configurable httpx transport that returns a pre-built response. Captures the outgoing request so tests can assert on it after the fact. """ def __init__(self, status_code: int = 201, body: dict | None = None): self.status_code = status_code self.body = body or _make_issue_response() self.last_request: httpx.Request | None = None async def handle_async_request(self, request: httpx.Request) -> httpx.Response: self.last_request = request content = json.dumps(self.body).encode() return httpx.Response( status_code=self.status_code, headers={"Content-Type": "application/json"}, content=content, ) def _make_tracker(transport: httpx.AsyncBaseTransport) -> GiteaIssueTracker: """Construct a GiteaIssueTracker wired to the given mock transport.""" tracker = GiteaIssueTracker( token="test-token-abc", owner="testowner", repo="testrepo", base_url="https://gitea.example.com", ) # Replace the internal client's transport with our mock. # We recreate the client so we don't have to expose the transport in __init__. tracker._client = httpx.AsyncClient( transport=transport, headers=tracker._headers, timeout=30.0, ) return tracker # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def good_transport(): """Mock transport that returns a successful 201 issue response.""" return _MockTransport(status_code=201) @pytest.fixture def error_transport(): """Mock transport that simulates a Gitea API 422 error.""" return _MockTransport( status_code=422, body={"message": "label does not exist"}, ) @pytest.fixture def good_tracker(good_transport): return _make_tracker(good_transport) @pytest.fixture def error_tracker(error_transport): return _make_tracker(error_transport) # --------------------------------------------------------------------------- # Port contract test # --------------------------------------------------------------------------- class TestPortContract: """GiteaIssueTracker must be a concrete subclass of IssueTracker.""" def test_is_subclass_of_issue_tracker_port(self): """The adapter satisfies the IssueTracker ABC — no missing abstract methods.""" assert issubclass(GiteaIssueTracker, IssueTracker) def test_instance_passes_isinstance_check(self, good_tracker): """An instantiated adapter is accepted anywhere IssueTracker is expected.""" assert isinstance(good_tracker, IssueTracker) # --------------------------------------------------------------------------- # Successful issue creation # --------------------------------------------------------------------------- class TestSuccessfulIssueCreation: """Happy-path behaviour when Gitea responds with 201.""" async def test_returns_html_url(self, good_tracker): """create_unanswered_issue should return the html_url from the API response.""" url = await good_tracker.create_unanswered_issue( question="Can I steal home?", user_id="user-42", channel_id="chan-99", attempted_rules=["5.2.1(b)", "5.2.2"], conversation_id="conv-abc", ) assert url == "https://gitea.example.com/owner/repo/issues/1" async def test_posts_to_correct_endpoint(self, good_tracker, good_transport): """The adapter must POST to /repos/{owner}/{repo}/issues.""" await good_tracker.create_unanswered_issue( question="Can I steal home?", user_id="user-42", channel_id="chan-99", attempted_rules=[], conversation_id="conv-abc", ) req = good_transport.last_request assert req is not None assert req.method == "POST" assert "/repos/testowner/testrepo/issues" in str(req.url) async def test_sends_bearer_token(self, good_tracker, good_transport): """Authorization header must carry the configured token.""" await good_tracker.create_unanswered_issue( question="test question", user_id="u", channel_id="c", attempted_rules=[], conversation_id="conv-1", ) req = good_transport.last_request assert req.headers["Authorization"] == "token test-token-abc" async def test_content_type_is_json(self, good_tracker, good_transport): """The request must declare application/json content type.""" await good_tracker.create_unanswered_issue( question="test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) req = good_transport.last_request assert req.headers["Content-Type"] == "application/json" async def test_also_accepts_200_status(self, good_tracker): """Some Gitea instances return 200 on issue creation; both are valid.""" transport_200 = _MockTransport(status_code=200) tracker = _make_tracker(transport_200) url = await tracker.create_unanswered_issue( question="Is 200 ok?", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) assert url == "https://gitea.example.com/owner/repo/issues/1" # --------------------------------------------------------------------------- # Issue body content # --------------------------------------------------------------------------- class TestIssueBodyContent: """The issue body must contain context needed for human triage.""" async def _get_body(self, transport, **kwargs) -> str: tracker = _make_tracker(transport) defaults = dict( question="Can I intentionally walk a batter?", user_id="user-99", channel_id="channel-7", attempted_rules=["4.1.1", "4.1.2"], conversation_id="conv-xyz", ) defaults.update(kwargs) await tracker.create_unanswered_issue(**defaults) req = transport.last_request return json.loads(req.content)["body"] async def test_body_contains_question_in_code_block(self, good_transport): """The question must be wrapped in a fenced code block to prevent markdown injection — a user could craft a question containing headers, links, or other markdown that would corrupt the issue layout.""" body = await self._get_body( good_transport, question="Can I intentionally walk a batter?" ) assert "```" in body assert "Can I intentionally walk a batter?" in body # Must be inside a fenced block (preceded by ```) fence_idx = body.index("```") question_idx = body.index("Can I intentionally walk a batter?") assert fence_idx < question_idx async def test_body_contains_user_id(self, good_transport): """User ID must appear in the body so reviewers know who asked.""" body = await self._get_body(good_transport, user_id="user-99") assert "user-99" in body async def test_body_contains_channel_id(self, good_transport): """Channel ID must appear so reviewers can locate the conversation.""" body = await self._get_body(good_transport, channel_id="channel-7") assert "channel-7" in body async def test_body_contains_conversation_id(self, good_transport): """Conversation ID must be present for traceability to the chat log.""" body = await self._get_body(good_transport, conversation_id="conv-xyz") assert "conv-xyz" in body async def test_body_contains_attempted_rules(self, good_transport): """Searched rule IDs must be listed so reviewers know what was tried.""" body = await self._get_body(good_transport, attempted_rules=["4.1.1", "4.1.2"]) assert "4.1.1" in body assert "4.1.2" in body async def test_body_handles_empty_attempted_rules(self, good_transport): """An empty rules list should not crash; body should gracefully note none.""" body = await self._get_body(good_transport, attempted_rules=[]) # Should not raise and body should still be a non-empty string assert isinstance(body, str) assert len(body) > 0 async def test_title_contains_truncated_question(self, good_transport): """Issue title should contain the question (truncated to ~80 chars).""" transport = good_transport tracker = _make_tracker(transport) long_question = "A" * 200 await tracker.create_unanswered_issue( question=long_question, user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) req = transport.last_request payload = json.loads(req.content) # Title should not be absurdly long — it should be truncated assert len(payload["title"]) < 150 # --------------------------------------------------------------------------- # Labels # --------------------------------------------------------------------------- class TestLabels: """Label tags must appear in the issue body text. The Gitea create-issue API expects label IDs (integers), not label names (strings). To avoid a 422 error, we omit the 'labels' field from the API payload and instead embed the label names as plain text in the issue body so reviewers can apply them manually or via a Gitea webhook/action. """ async def test_labels_not_in_request_payload(self, good_tracker, good_transport): """The 'labels' key must be absent from the POST payload to avoid a 422 Unprocessable Entity — Gitea expects integer IDs, not name strings.""" await good_tracker.create_unanswered_issue( question="test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) payload = json.loads(good_transport.last_request.content) assert "labels" not in payload async def test_label_tags_present_in_body(self, good_tracker, good_transport): """Label names should appear in the issue body text so reviewers can identify the issue origin and apply labels manually or via automation. We require 'rules-gap', 'ai-generated', and 'needs-review' to be present so that Gitea project boards can be populated correctly. """ await good_tracker.create_unanswered_issue( question="test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) body = json.loads(good_transport.last_request.content)["body"] assert "rules-gap" in body assert "needs-review" in body assert "ai-generated" in body # --------------------------------------------------------------------------- # API error handling # --------------------------------------------------------------------------- class TestAPIErrorHandling: """Non-2xx responses from Gitea should raise a descriptive RuntimeError.""" async def test_raises_on_422(self, error_tracker): """A 422 Unprocessable Entity should raise RuntimeError with status info.""" with pytest.raises(RuntimeError) as exc_info: await error_tracker.create_unanswered_issue( question="bad label question", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) msg = str(exc_info.value) assert "422" in msg async def test_raises_on_401(self): """A 401 Unauthorized (bad token) should raise RuntimeError.""" transport = _MockTransport(status_code=401, body={"message": "Unauthorized"}) tracker = _make_tracker(transport) with pytest.raises(RuntimeError) as exc_info: await tracker.create_unanswered_issue( question="test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) assert "401" in str(exc_info.value) async def test_raises_on_500(self): """A 500 server error should raise RuntimeError, not silently return empty.""" transport = _MockTransport( status_code=500, body={"message": "Internal Server Error"} ) tracker = _make_tracker(transport) with pytest.raises(RuntimeError) as exc_info: await tracker.create_unanswered_issue( question="test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) assert "500" in str(exc_info.value) async def test_error_message_includes_response_body(self, error_tracker): """The RuntimeError message should embed the raw API error body to aid debugging — operators need to know whether the failure was a bad label, an auth issue, a quota error, etc.""" with pytest.raises(RuntimeError) as exc_info: await error_tracker.create_unanswered_issue( question="test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) # The error transport returns {"message": "label does not exist"} assert "label does not exist" in str(exc_info.value) # --------------------------------------------------------------------------- # Lifecycle — persistent client # --------------------------------------------------------------------------- class TestClientLifecycle: """The adapter must expose a close() coroutine for clean resource teardown.""" async def test_close_is_callable(self, good_tracker): """close() should exist and be awaitable (used in dependency teardown).""" # Should not raise await good_tracker.close() async def test_close_after_request_does_not_raise(self, good_tracker): """Closing after making a real request should be clean.""" await good_tracker.create_unanswered_issue( question="cleanup test", user_id="u", channel_id="c", attempted_rules=[], conversation_id="c1", ) await good_tracker.close() # should not raise