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>
412 lines
15 KiB
Python
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
|