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>
267 lines
11 KiB
Python
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"
|