"""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: """Labels must be passed to the Gitea API in the request payload.""" async def test_labels_present_in_request_payload( self, good_tracker, good_transport ): """The adapter should send a 'labels' field in the POST body.""" 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" in payload assert isinstance(payload["labels"], list) assert len(payload["labels"]) > 0 async def test_expected_label_values(self, good_tracker, good_transport): """Labels should identify the issue origin clearly. We require at least 'rules-gap' or equivalent, 'ai-generated', and 'needs-review' so that Gitea project boards can filter automatically. """ 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) labels = payload["labels"] assert "rules-gap" in labels assert "needs-review" in labels assert "ai-generated" in labels # --------------------------------------------------------------------------- # 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