strat-chatbot/tests/adapters/test_sqlite_convos.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

267 lines
11 KiB
Python

"""Tests for the SQLiteConversationStore outbound adapter.
Uses an in-memory SQLite database (sqlite+aiosqlite://) so each test is fast
and hermetic — no file I/O, no shared state between tests.
What we verify:
- A fresh conversation can be created and its ID returned.
- Calling get_or_create_conversation with an existing ID returns the same ID
(and does NOT create a new row).
- Calling get_or_create_conversation with an unknown/missing ID creates a new
conversation (graceful fallback rather than a hard error).
- Messages can be appended to a conversation; each returns a unique ID.
- get_conversation_history returns messages in chronological order (oldest
first), not insertion-reverse order.
- The limit parameter is respected; when more messages exist than the limit,
only the most-recent `limit` messages come back (still chronological within
that window).
- The returned dicts have exactly the keys {"role", "content"}, matching the
OpenAI-compatible format expected by the LLM port.
"""
import pytest
from adapters.outbound.sqlite_convos import SQLiteConversationStore
IN_MEMORY_URL = "sqlite+aiosqlite://"
@pytest.fixture
async def store() -> SQLiteConversationStore:
"""Create an initialised in-memory store for a single test.
The fixture is async because init_db() is a coroutine that runs the
CREATE TABLE statements. Each test gets a completely fresh database
because in-memory SQLite databases are private to the connection that
created them.
"""
s = SQLiteConversationStore(db_url=IN_MEMORY_URL)
await s.init_db()
return s
# ---------------------------------------------------------------------------
# Conversation creation
# ---------------------------------------------------------------------------
async def test_create_new_conversation(store: SQLiteConversationStore):
"""get_or_create_conversation should return a non-empty string ID when no
existing conversation_id is supplied."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
assert isinstance(conv_id, str)
assert len(conv_id) > 0
async def test_create_conversation_returns_uuid_format(
store: SQLiteConversationStore,
):
"""The generated conversation ID should look like a UUID (36-char with
hyphens), since we use uuid.uuid4() internally."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
# UUID4 format: 8-4-4-4-12 hex digits separated by hyphens = 36 chars
assert len(conv_id) == 36
assert conv_id.count("-") == 4
# ---------------------------------------------------------------------------
# Idempotency — fetching an existing conversation
# ---------------------------------------------------------------------------
async def test_get_existing_conversation_returns_same_id(
store: SQLiteConversationStore,
):
"""Passing an existing conversation_id back into get_or_create_conversation
must return exactly that same ID, not create a new one."""
original_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
fetched_id = await store.get_or_create_conversation(
user_id="u1", channel_id="ch1", conversation_id=original_id
)
assert fetched_id == original_id
async def test_get_unknown_conversation_id_creates_new(
store: SQLiteConversationStore,
):
"""If conversation_id is provided but not found in the DB, the adapter
should gracefully create a fresh conversation rather than raise."""
new_id = await store.get_or_create_conversation(
user_id="u2",
channel_id="ch2",
conversation_id="00000000-0000-0000-0000-000000000000",
)
assert isinstance(new_id, str)
# The returned ID must differ from the bogus one we passed in.
assert new_id != "00000000-0000-0000-0000-000000000000"
# ---------------------------------------------------------------------------
# Adding messages
# ---------------------------------------------------------------------------
async def test_add_message_returns_string_id(store: SQLiteConversationStore):
"""add_message should return a non-empty string ID for the new message."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
msg_id = await store.add_message(
conversation_id=conv_id, content="Hello!", is_user=True
)
assert isinstance(msg_id, str)
assert len(msg_id) > 0
async def test_add_multiple_messages_returns_unique_ids(
store: SQLiteConversationStore,
):
"""Every call to add_message must produce a distinct message ID."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
id1 = await store.add_message(conv_id, "Hi", is_user=True)
id2 = await store.add_message(conv_id, "Hello back", is_user=False)
assert id1 != id2
async def test_add_message_with_parent_id(store: SQLiteConversationStore):
"""add_message should accept an optional parent_id without error. We
cannot easily inspect the raw DB row here, but we verify that the call
succeeds and returns an ID."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
parent_id = await store.add_message(conv_id, "parent msg", is_user=True)
child_id = await store.add_message(
conv_id, "child msg", is_user=False, parent_id=parent_id
)
assert isinstance(child_id, str)
assert child_id != parent_id
# ---------------------------------------------------------------------------
# Conversation history — format
# ---------------------------------------------------------------------------
async def test_history_returns_list_of_dicts(store: SQLiteConversationStore):
"""get_conversation_history must return a list of dicts."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
await store.add_message(conv_id, "Hello", is_user=True)
history = await store.get_conversation_history(conv_id)
assert isinstance(history, list)
assert len(history) == 1
assert isinstance(history[0], dict)
async def test_history_dict_has_role_and_content_keys(
store: SQLiteConversationStore,
):
"""Each dict in the history must have exactly the keys 'role' and
'content', matching the OpenAI chat-completion message format."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
await store.add_message(conv_id, "A question", is_user=True)
await store.add_message(conv_id, "An answer", is_user=False)
history = await store.get_conversation_history(conv_id)
for entry in history:
assert set(entry.keys()) == {
"role",
"content",
}, f"Expected keys {{'role','content'}}, got {set(entry.keys())}"
async def test_history_role_mapping(store: SQLiteConversationStore):
"""is_user=True maps to role='user'; is_user=False maps to
role='assistant'."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
await store.add_message(conv_id, "user msg", is_user=True)
await store.add_message(conv_id, "assistant msg", is_user=False)
history = await store.get_conversation_history(conv_id)
roles = [e["role"] for e in history]
assert "user" in roles
assert "assistant" in roles
# ---------------------------------------------------------------------------
# Conversation history — ordering
# ---------------------------------------------------------------------------
async def test_history_is_chronological(store: SQLiteConversationStore):
"""Messages must come back oldest-first (chronological), NOT newest-first.
The underlying query orders DESC then reverses, so the first item in the
returned list must have the content of the first message we inserted.
"""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
await store.add_message(conv_id, "first", is_user=True)
await store.add_message(conv_id, "second", is_user=False)
await store.add_message(conv_id, "third", is_user=True)
history = await store.get_conversation_history(conv_id, limit=10)
contents = [e["content"] for e in history]
assert contents == [
"first",
"second",
"third",
], f"Expected chronological order, got: {contents}"
# ---------------------------------------------------------------------------
# Conversation history — limit
# ---------------------------------------------------------------------------
async def test_history_limit_respected(store: SQLiteConversationStore):
"""When there are more messages than the limit, only `limit` messages are
returned."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
for i in range(5):
await store.add_message(conv_id, f"msg {i}", is_user=(i % 2 == 0))
history = await store.get_conversation_history(conv_id, limit=3)
assert len(history) == 3
async def test_history_limit_returns_most_recent(
store: SQLiteConversationStore,
):
"""When the limit truncates results, the MOST RECENT messages should be
included, not the oldest ones. After inserting 5 messages (0-4) and
requesting limit=2, we expect messages 3 and 4 (in chronological order)."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
for i in range(5):
await store.add_message(conv_id, f"msg {i}", is_user=(i % 2 == 0))
history = await store.get_conversation_history(conv_id, limit=2)
contents = [e["content"] for e in history]
assert contents == [
"msg 3",
"msg 4",
], f"Expected the 2 most-recent messages in order, got: {contents}"
async def test_history_empty_conversation(store: SQLiteConversationStore):
"""A conversation with no messages returns an empty list, not an error."""
conv_id = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
history = await store.get_conversation_history(conv_id)
assert history == []
# ---------------------------------------------------------------------------
# Isolation between conversations
# ---------------------------------------------------------------------------
async def test_history_isolated_between_conversations(
store: SQLiteConversationStore,
):
"""Messages from one conversation must not appear in another conversation's
history."""
conv_a = await store.get_or_create_conversation(user_id="u1", channel_id="ch1")
conv_b = await store.get_or_create_conversation(user_id="u2", channel_id="ch2")
await store.add_message(conv_a, "from A", is_user=True)
await store.add_message(conv_b, "from B", is_user=True)
history_a = await store.get_conversation_history(conv_a)
history_b = await store.get_conversation_history(conv_b)
assert len(history_a) == 1
assert history_a[0]["content"] == "from A"
assert len(history_b) == 1
assert history_b[0]["content"] == "from B"