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