"""Unit tests for ConversationManagerService.""" import asyncio from datetime import UTC, datetime, timedelta from typing import Literal from unittest.mock import AsyncMock, Mock, patch from uuid import uuid4 import pytest from capa_de_integracion.config import Settings from capa_de_integracion.models import ( ConversationEntry, ConversationRequest, ConversationSession, DetectIntentResponse, QueryResult, User, ) from capa_de_integracion.services.conversation import ConversationManagerService from capa_de_integracion.services.dlp import DLPService from capa_de_integracion.services.rag import RAGServiceBase from capa_de_integracion.services.storage.firestore import FirestoreService from capa_de_integracion.services.storage.redis import RedisService @pytest.fixture def mock_settings() -> Settings: """Create mock settings.""" settings = Mock(spec=Settings) settings.dlp_template_complete_flow = "test_template" settings.conversation_context_message_limit = 60 settings.conversation_context_days_limit = 30 return settings @pytest.fixture def mock_dlp() -> DLPService: """Create mock DLP service.""" dlp = Mock(spec=DLPService) dlp.get_obfuscated_string = AsyncMock(return_value="obfuscated message") return dlp @pytest.fixture def mock_rag() -> RAGServiceBase: """Create mock RAG service.""" rag = Mock(spec=RAGServiceBase) rag.query = AsyncMock(return_value="RAG response") return rag @pytest.fixture def mock_redis() -> RedisService: """Create mock Redis service.""" redis = Mock(spec=RedisService) redis.get_session = AsyncMock(return_value=None) redis.save_session = AsyncMock() redis.get_notification_session = AsyncMock(return_value=None) redis.delete_notification_session = AsyncMock() return redis @pytest.fixture def mock_firestore() -> FirestoreService: """Create mock Firestore service.""" firestore = Mock(spec=FirestoreService) firestore.get_session_by_phone = AsyncMock(return_value=None) firestore.create_session = AsyncMock() firestore.save_session = AsyncMock() firestore.save_entry = AsyncMock() firestore.get_entries = AsyncMock(return_value=[]) firestore.update_notification_status = AsyncMock() # Mock db.collection for notifications mock_doc = Mock() mock_doc.exists = False mock_doc_ref = Mock() mock_doc_ref.get = AsyncMock(return_value=mock_doc) mock_collection = Mock() mock_collection.document = Mock(return_value=mock_doc_ref) firestore.db = Mock() firestore.db.collection = Mock(return_value=mock_collection) firestore.notifications_collection = "notifications" return firestore @pytest.fixture def conversation_service( mock_settings: Settings, mock_rag: RAGServiceBase, mock_redis: RedisService, mock_firestore: FirestoreService, mock_dlp: DLPService, ) -> ConversationManagerService: """Create conversation service with mocked dependencies.""" with patch( "capa_de_integracion.services.conversation.QuickReplyContentService" ): service = ConversationManagerService( settings=mock_settings, rag_service=mock_rag, redis_service=mock_redis, firestore_service=mock_firestore, dlp_service=mock_dlp, ) return service @pytest.fixture def sample_session() -> ConversationSession: """Create a sample conversation session.""" return ConversationSession( session_id="test_session_123", user_id="user_by_phone_1234567890", telefono="1234567890", last_modified=datetime.now(UTC), last_message="Hello", pantalla_contexto=None, ) @pytest.fixture def sample_request() -> ConversationRequest: """Create a sample conversation request.""" return ConversationRequest( mensaje="Hello, I need help", usuario=User( telefono="1234567890", nickname="TestUser", ), canal="whatsapp", ) # ============================================================================ # Test Session Management # ============================================================================ class TestSessionManagement: """Tests for session management methods.""" @pytest.mark.asyncio async def test_obtain_session_from_redis( self, conversation_service: ConversationManagerService, mock_redis: RedisService, sample_session: ConversationSession, ) -> None: """Test obtaining session from Redis.""" mock_redis.get_session = AsyncMock(return_value=sample_session) result = await conversation_service._obtain_or_create_session("1234567890") assert result == sample_session mock_redis.get_session.assert_awaited_once_with("1234567890") @pytest.mark.asyncio async def test_obtain_session_from_firestore_when_redis_miss( self, conversation_service: ConversationManagerService, mock_redis: RedisService, mock_firestore: FirestoreService, sample_session: ConversationSession, ) -> None: """Test obtaining session from Firestore when Redis misses.""" mock_redis.get_session = AsyncMock(return_value=None) mock_firestore.get_session_by_phone = AsyncMock(return_value=sample_session) result = await conversation_service._obtain_or_create_session("1234567890") assert result == sample_session mock_redis.get_session.assert_awaited_once() mock_firestore.get_session_by_phone.assert_awaited_once_with("1234567890") @pytest.mark.asyncio async def test_create_new_session_when_both_miss( self, conversation_service: ConversationManagerService, mock_redis: RedisService, mock_firestore: FirestoreService, sample_session: ConversationSession, ) -> None: """Test creating new session when both Redis and Firestore miss.""" mock_redis.get_session = AsyncMock(return_value=None) mock_firestore.get_session_by_phone = AsyncMock(return_value=None) mock_firestore.create_session = AsyncMock(return_value=sample_session) result = await conversation_service._obtain_or_create_session("1234567890") assert result == sample_session mock_firestore.create_session.assert_awaited_once() # Verify the session was auto-cached to Redis mock_redis.save_session.assert_awaited_once_with(sample_session) @pytest.mark.asyncio async def test_session_auto_cached_to_redis( self, conversation_service: ConversationManagerService, mock_redis: RedisService, mock_firestore: FirestoreService, sample_session: ConversationSession, ) -> None: """Test that newly created session is auto-cached to Redis.""" mock_redis.get_session = AsyncMock(return_value=None) mock_firestore.get_session_by_phone = AsyncMock(return_value=None) mock_firestore.create_session = AsyncMock(return_value=sample_session) await conversation_service._obtain_or_create_session("1234567890") mock_redis.save_session.assert_awaited_once_with(sample_session) # ============================================================================ # Test Entry Persistence # ============================================================================ class TestEntryPersistence: """Tests for conversation entry persistence methods.""" @pytest.mark.asyncio async def test_save_conversation_turn_with_conversacion_type( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, ) -> None: """Test saving conversation turn with CONVERSACION type.""" await conversation_service._save_conversation_turn( session_id="test_session", user_text="Hello", assistant_text="Hi there", entry_type="CONVERSACION", canal="whatsapp", ) assert mock_firestore.save_entry.await_count == 2 # Verify user entry user_call = mock_firestore.save_entry.await_args_list[0] assert user_call[0][0] == "test_session" user_entry = user_call[0][1] assert user_entry.entity == "user" assert user_entry.text == "Hello" assert user_entry.type == "CONVERSACION" assert user_entry.canal == "whatsapp" # Verify assistant entry assistant_call = mock_firestore.save_entry.await_args_list[1] assistant_entry = assistant_call[0][1] assert assistant_entry.entity == "assistant" assert assistant_entry.text == "Hi there" assert assistant_entry.type == "CONVERSACION" @pytest.mark.asyncio async def test_save_conversation_turn_with_llm_type( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, ) -> None: """Test saving conversation turn with LLM type.""" await conversation_service._save_conversation_turn( session_id="test_session", user_text="What's the weather?", assistant_text="It's sunny", entry_type="LLM", canal="telegram", ) assert mock_firestore.save_entry.await_count == 2 assistant_call = mock_firestore.save_entry.await_args_list[1] assistant_entry = assistant_call[0][1] assert assistant_entry.type == "LLM" @pytest.mark.asyncio async def test_save_conversation_turn_with_canal( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, ) -> None: """Test saving conversation turn with canal parameter.""" await conversation_service._save_conversation_turn( session_id="test_session", user_text="Test", assistant_text="Response", entry_type="CONVERSACION", canal="sms", ) user_call = mock_firestore.save_entry.await_args_list[0] user_entry = user_call[0][1] assert user_entry.canal == "sms" @pytest.mark.asyncio async def test_save_conversation_turn_without_canal( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, ) -> None: """Test saving conversation turn without canal parameter.""" await conversation_service._save_conversation_turn( session_id="test_session", user_text="Test", assistant_text="Response", entry_type="CONVERSACION", ) user_call = mock_firestore.save_entry.await_args_list[0] user_entry = user_call[0][1] assert user_entry.canal is None # ============================================================================ # Test Session Updates # ============================================================================ class TestSessionUpdates: """Tests for session update methods.""" @pytest.mark.asyncio async def test_update_session_sets_last_message( self, conversation_service: ConversationManagerService, sample_session: ConversationSession, ) -> None: """Test that update_session sets last_message.""" await conversation_service._update_session_after_turn( session=sample_session, last_message="New message", ) assert sample_session.last_message == "New message" @pytest.mark.asyncio async def test_update_session_sets_timestamp( self, conversation_service: ConversationManagerService, sample_session: ConversationSession, ) -> None: """Test that update_session sets timestamp.""" old_timestamp = sample_session.last_modified await conversation_service._update_session_after_turn( session=sample_session, last_message="New message", ) assert sample_session.last_modified > old_timestamp @pytest.mark.asyncio async def test_update_session_saves_to_firestore( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, sample_session: ConversationSession, ) -> None: """Test that update_session saves to Firestore.""" await conversation_service._update_session_after_turn( session=sample_session, last_message="New message", ) mock_firestore.save_session.assert_awaited_once_with(sample_session) @pytest.mark.asyncio async def test_update_session_saves_to_redis( self, conversation_service: ConversationManagerService, mock_redis: RedisService, sample_session: ConversationSession, ) -> None: """Test that update_session saves to Redis.""" await conversation_service._update_session_after_turn( session=sample_session, last_message="New message", ) mock_redis.save_session.assert_awaited_once_with(sample_session) # ============================================================================ # Test Quick Reply Path # ============================================================================ class TestQuickReplyPath: """Tests for quick reply path handling.""" @pytest.mark.asyncio async def test_quick_reply_path_with_valid_context( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test quick reply path with valid pantalla_contexto.""" sample_session.pantalla_contexto = "screen_123" sample_session.last_modified = datetime.now(UTC) # Mock quick reply service mock_response = DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Quick reply response"), ) conversation_service._manage_quick_reply_conversation = AsyncMock( return_value=mock_response ) result = await conversation_service._handle_quick_reply_path( request=sample_request, session=sample_session, ) assert result == mock_response @pytest.mark.asyncio async def test_quick_reply_path_with_stale_context_returns_none( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test quick reply path with stale pantalla_contexto returns None.""" sample_session.pantalla_contexto = "screen_123" # Set timestamp to 11 minutes ago (stale) sample_session.last_modified = datetime.now(UTC) - timedelta(minutes=11) result = await conversation_service._handle_quick_reply_path( request=sample_request, session=sample_session, ) assert result is None @pytest.mark.asyncio async def test_quick_reply_path_without_context_returns_none( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test quick reply path without pantalla_contexto returns None.""" sample_session.pantalla_contexto = None result = await conversation_service._handle_quick_reply_path( request=sample_request, session=sample_session, ) assert result is None @pytest.mark.asyncio async def test_quick_reply_path_saves_entries( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test quick reply path saves conversation entries.""" sample_session.pantalla_contexto = "screen_123" sample_session.last_modified = datetime.now(UTC) mock_response = DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Quick reply response"), ) conversation_service._manage_quick_reply_conversation = AsyncMock( return_value=mock_response ) await conversation_service._handle_quick_reply_path( request=sample_request, session=sample_session, ) await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete assert mock_firestore.save_entry.await_count == 2 @pytest.mark.asyncio async def test_quick_reply_path_updates_session( self, conversation_service: ConversationManagerService, mock_redis: RedisService, mock_firestore: FirestoreService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test quick reply path updates session.""" sample_session.pantalla_contexto = "screen_123" sample_session.last_modified = datetime.now(UTC) mock_response = DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Quick reply response"), ) conversation_service._manage_quick_reply_conversation = AsyncMock( return_value=mock_response ) await conversation_service._handle_quick_reply_path( request=sample_request, session=sample_session, ) await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete mock_firestore.save_session.assert_awaited_once() mock_redis.save_session.assert_awaited_once() # ============================================================================ # Test Standard Conversation Path # ============================================================================ class TestStandardConversation: """Tests for standard conversation flow.""" @pytest.mark.asyncio async def test_standard_conversation_loads_history( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test standard conversation loads history for old sessions.""" # Make session older than 30 minutes to trigger history loading old_session = ConversationSession( session_id=sample_session.session_id, user_id=sample_session.user_id, telefono=sample_session.telefono, created_at=datetime.now(UTC) - timedelta(minutes=45), last_modified=sample_session.last_modified, last_message=sample_session.last_message, pantalla_contexto=sample_session.pantalla_contexto, ) await conversation_service._handle_standard_conversation( request=sample_request, session=old_session, ) mock_firestore.get_entries.assert_awaited_once_with( old_session.session_id, limit=60, ) @pytest.mark.asyncio async def test_standard_conversation_queries_rag( self, conversation_service: ConversationManagerService, mock_rag: RAGServiceBase, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test standard conversation queries RAG service.""" await conversation_service._handle_standard_conversation( request=sample_request, session=sample_session, ) mock_rag.query.assert_awaited_once() @pytest.mark.asyncio async def test_standard_conversation_saves_entries( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test standard conversation saves entries.""" await conversation_service._handle_standard_conversation( request=sample_request, session=sample_session, ) await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete assert mock_firestore.save_entry.await_count == 2 @pytest.mark.asyncio async def test_standard_conversation_updates_session( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, mock_redis: RedisService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test standard conversation updates session.""" await conversation_service._handle_standard_conversation( request=sample_request, session=sample_session, ) await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete # save_session is called in _update_session_after_turn assert mock_firestore.save_session.await_count >= 1 assert mock_redis.save_session.await_count >= 1 @pytest.mark.asyncio async def test_standard_conversation_marks_notifications_processed( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test standard conversation marks notifications as processed.""" # Mock that there are active notifications conversation_service._get_active_notifications = AsyncMock( return_value=[Mock(texto="Test notification")] ) await conversation_service._handle_standard_conversation( request=sample_request, session=sample_session, ) await asyncio.sleep(0.01) # Let fire-and-forget background tasks complete mock_firestore.update_notification_status.assert_awaited_once() @pytest.mark.asyncio async def test_standard_conversation_without_notifications( self, conversation_service: ConversationManagerService, mock_firestore: FirestoreService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test standard conversation without notifications.""" conversation_service._get_active_notifications = AsyncMock(return_value=[]) await conversation_service._handle_standard_conversation( request=sample_request, session=sample_session, ) mock_firestore.update_notification_status.assert_not_awaited() # ============================================================================ # Test Orchestration # ============================================================================ class TestOrchestration: """Tests for main orchestration logic.""" @pytest.mark.asyncio async def test_manage_conversation_applies_dlp( self, conversation_service: ConversationManagerService, mock_dlp: DLPService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test manage_conversation applies DLP obfuscation.""" conversation_service._obtain_or_create_session = AsyncMock( return_value=sample_session ) conversation_service._handle_standard_conversation = AsyncMock( return_value=DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Response"), ) ) await conversation_service.manage_conversation(sample_request) mock_dlp.get_obfuscated_string.assert_awaited_once() assert sample_request.mensaje == "obfuscated message" @pytest.mark.asyncio async def test_manage_conversation_obtains_session( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test manage_conversation obtains session.""" conversation_service._obtain_or_create_session = AsyncMock( return_value=sample_session ) conversation_service._handle_standard_conversation = AsyncMock( return_value=DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Response"), ) ) await conversation_service.manage_conversation(sample_request) conversation_service._obtain_or_create_session.assert_awaited_once_with( "1234567890" ) @pytest.mark.asyncio async def test_manage_conversation_uses_quick_reply_path_when_valid( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test manage_conversation uses quick reply path when valid.""" sample_session.pantalla_contexto = "screen_123" sample_session.last_modified = datetime.now(UTC) conversation_service._obtain_or_create_session = AsyncMock( return_value=sample_session ) mock_response = DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Quick reply"), ) conversation_service._handle_quick_reply_path = AsyncMock( return_value=mock_response ) conversation_service._handle_standard_conversation = AsyncMock() result = await conversation_service.manage_conversation(sample_request) assert result == mock_response conversation_service._handle_quick_reply_path.assert_awaited_once() conversation_service._handle_standard_conversation.assert_not_awaited() @pytest.mark.asyncio async def test_manage_conversation_uses_standard_path_when_no_context( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test manage_conversation uses standard path when no context.""" sample_session.pantalla_contexto = None conversation_service._obtain_or_create_session = AsyncMock( return_value=sample_session ) mock_response = DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Standard response"), ) conversation_service._handle_standard_conversation = AsyncMock( return_value=mock_response ) result = await conversation_service.manage_conversation(sample_request) assert result == mock_response conversation_service._handle_standard_conversation.assert_awaited_once() @pytest.mark.asyncio async def test_manage_conversation_uses_standard_path_when_stale_context( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, sample_session: ConversationSession, ) -> None: """Test manage_conversation uses standard path when context is stale.""" sample_session.pantalla_contexto = "screen_123" sample_session.last_modified = datetime.now(UTC) - timedelta(minutes=11) conversation_service._obtain_or_create_session = AsyncMock( return_value=sample_session ) mock_response = DetectIntentResponse( responseId=str(uuid4()), queryResult=QueryResult(responseText="Standard response"), ) conversation_service._handle_quick_reply_path = AsyncMock(return_value=None) conversation_service._handle_standard_conversation = AsyncMock( return_value=mock_response ) result = await conversation_service.manage_conversation(sample_request) assert result == mock_response conversation_service._handle_standard_conversation.assert_awaited_once() @pytest.mark.asyncio async def test_manage_conversation_handles_exceptions( self, conversation_service: ConversationManagerService, sample_request: ConversationRequest, ) -> None: """Test manage_conversation handles exceptions properly.""" conversation_service._obtain_or_create_session = AsyncMock( side_effect=Exception("Test error") ) with pytest.raises(Exception, match="Test error"): await conversation_service.manage_conversation(sample_request)