strat-chatbot/tests/domain/test_models.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

201 lines
5.9 KiB
Python

"""Tests for domain models — pure data structures with no framework dependencies."""
from datetime import datetime, timezone
from domain.models import (
RuleDocument,
RuleSearchResult,
Conversation,
ChatMessage,
LLMResponse,
ChatResult,
)
class TestRuleDocument:
"""RuleDocument holds rule content with metadata for the knowledge base."""
def test_create_with_required_fields(self):
doc = RuleDocument(
rule_id="5.2.1(b)",
title="Stolen Base Attempts",
section="Baserunning",
content="When a runner attempts to steal...",
source_file="data/rules/baserunning.md",
)
assert doc.rule_id == "5.2.1(b)"
assert doc.title == "Stolen Base Attempts"
assert doc.section == "Baserunning"
assert doc.parent_rule is None
assert doc.page_ref is None
def test_optional_fields(self):
doc = RuleDocument(
rule_id="5.2",
title="Baserunning Overview",
section="Baserunning",
content="Overview content",
source_file="rules.md",
parent_rule="5",
page_ref="32",
)
assert doc.parent_rule == "5"
assert doc.page_ref == "32"
def test_metadata_dict_for_vector_store(self):
"""to_metadata() returns a flat dict suitable for ChromaDB/vector store metadata."""
doc = RuleDocument(
rule_id="5.2.1(b)",
title="Stolen Base Attempts",
section="Baserunning",
content="content",
source_file="rules.md",
parent_rule="5.2",
page_ref="32",
)
meta = doc.to_metadata()
assert meta == {
"rule_id": "5.2.1(b)",
"title": "Stolen Base Attempts",
"section": "Baserunning",
"parent_rule": "5.2",
"page_ref": "32",
"source_file": "rules.md",
}
def test_metadata_dict_empty_optionals(self):
"""Optional fields should be empty strings in metadata (not None) for vector stores."""
doc = RuleDocument(
rule_id="1.0",
title="General",
section="General",
content="c",
source_file="f.md",
)
meta = doc.to_metadata()
assert meta["parent_rule"] == ""
assert meta["page_ref"] == ""
class TestRuleSearchResult:
"""RuleSearchResult is what comes back from a semantic search."""
def test_create(self):
result = RuleSearchResult(
rule_id="5.2.1(b)",
title="Stolen Base Attempts",
content="When a runner attempts...",
section="Baserunning",
similarity=0.85,
)
assert result.similarity == 0.85
def test_similarity_bounds(self):
"""Similarity must be between 0.0 and 1.0."""
import pytest
with pytest.raises(ValueError):
RuleSearchResult(
rule_id="x", title="t", content="c", section="s", similarity=-0.1
)
with pytest.raises(ValueError):
RuleSearchResult(
rule_id="x", title="t", content="c", section="s", similarity=1.1
)
class TestConversation:
"""Conversation tracks a chat session between a user and the bot."""
def test_create_with_defaults(self):
conv = Conversation(
id="conv-123",
user_id="user-456",
channel_id="chan-789",
)
assert conv.id == "conv-123"
assert isinstance(conv.created_at, datetime)
assert isinstance(conv.last_activity, datetime)
def test_explicit_timestamps(self):
ts = datetime(2026, 1, 1, tzinfo=timezone.utc)
conv = Conversation(
id="c",
user_id="u",
channel_id="ch",
created_at=ts,
last_activity=ts,
)
assert conv.created_at == ts
class TestChatMessage:
"""ChatMessage is a single message in a conversation."""
def test_user_message(self):
msg = ChatMessage(
id="msg-1",
conversation_id="conv-1",
content="What is the steal rule?",
is_user=True,
)
assert msg.is_user is True
assert msg.parent_id is None
def test_assistant_message_with_parent(self):
msg = ChatMessage(
id="msg-2",
conversation_id="conv-1",
content="According to Rule 5.2.1(b)...",
is_user=False,
parent_id="msg-1",
)
assert msg.parent_id == "msg-1"
class TestLLMResponse:
"""LLMResponse is the structured output from the LLM port."""
def test_create(self):
resp = LLMResponse(
answer="Based on Rule 5.2.1(b), runners can steal...",
cited_rules=["5.2.1(b)"],
confidence=0.9,
needs_human=False,
)
assert resp.answer.startswith("Based on")
assert resp.confidence == 0.9
def test_defaults(self):
resp = LLMResponse(answer="text")
assert resp.cited_rules == []
assert resp.confidence == 0.5
assert resp.needs_human is False
class TestChatResult:
"""ChatResult is the final result returned by ChatService to inbound adapters."""
def test_create(self):
result = ChatResult(
response="answer text",
conversation_id="conv-1",
message_id="msg-2",
parent_message_id="msg-1",
cited_rules=["5.2.1(b)"],
confidence=0.85,
needs_human=False,
)
assert result.response == "answer text"
assert result.parent_message_id == "msg-1"
def test_optional_parent(self):
result = ChatResult(
response="r",
conversation_id="c",
message_id="m",
cited_rules=[],
confidence=0.5,
needs_human=False,
)
assert result.parent_message_id is None