Misc improvements

This commit is contained in:
2026-02-20 06:59:31 +00:00
parent 734cade8d9
commit e9d80def08
33 changed files with 1844 additions and 420 deletions

View File

@@ -0,0 +1,775 @@
"""Unit tests for ConversationManagerService."""
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,
)
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,
)
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."""
await conversation_service._handle_standard_conversation(
request=sample_request,
session=sample_session,
)
mock_firestore.get_entries.assert_awaited_once_with(
sample_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,
)
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,
)
# 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,
)
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)