Files
int-layer/tests/services/test_conversation_service.py
2026-02-20 20:38:59 +00:00

793 lines
28 KiB
Python

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