strat-chatbot/tests/adapters/test_gitea_issues.py
Cal Corum c3218f70c4 refactor: hexagonal architecture with ports & adapters, DI, and test-first development
Domain layer (zero framework imports):
- domain/models.py: pure dataclasses (RuleDocument, RuleSearchResult,
  Conversation, ChatMessage, LLMResponse, ChatResult)
- domain/ports.py: ABC interfaces (RuleRepository, LLMPort,
  ConversationStore, IssueTracker)
- domain/services.py: ChatService orchestrates Q&A flow using only ports

Outbound adapters (implement domain ports):
- adapters/outbound/openrouter.py: OpenRouterLLM with persistent httpx
  client, robust JSON parsing, regex citation fallback
- adapters/outbound/sqlite_convos.py: SQLiteConversationStore with
  async_sessionmaker, timezone-aware datetimes, cleanup support
- adapters/outbound/gitea_issues.py: GiteaIssueTracker with markdown
  injection protection (fenced code blocks)
- adapters/outbound/chroma_rules.py: ChromaRuleRepository with clamped
  similarity scores

Inbound adapter:
- adapters/inbound/api.py: thin FastAPI router with input validation
  (max_length constraints), proper HTTP status codes (503 for missing LLM)

Configuration & wiring:
- config/settings.py: Pydantic v2 SettingsConfigDict (no module-level singleton)
- config/container.py: create_app() factory with lifespan-managed DI
- main.py: minimal entry point

Test infrastructure (90 tests, all passing):
- tests/fakes/: in-memory implementations of all 4 ports
- tests/domain/: 26 tests for models and ChatService
- tests/adapters/: 64 tests for all adapters using fakes/mocks
- No real API calls, no model downloads, no disk I/O in fast tests

Also fixes: aiosqlite version constraint (>=0.19.0), adds hatch build
targets for new package layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 15:51:16 -05:00

412 lines
15 KiB
Python

"""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