diff --git a/packages/rag-client/README.md b/packages/rag-client/README.md deleted file mode 100644 index e69de29..0000000 diff --git a/packages/rag-client/pyproject.toml b/packages/rag-client/pyproject.toml deleted file mode 100644 index 6724e68..0000000 --- a/packages/rag-client/pyproject.toml +++ /dev/null @@ -1,17 +0,0 @@ -[project] -name = "rag-client" -version = "0.1.0" -description = "RAG client library with HTTP and Echo implementations" -readme = "README.md" -authors = [ - { name = "A8065384", email = "anibal.angulo.cardoza@banorte.com" } -] -requires-python = ">=3.12" -dependencies = [ - "httpx>=0.27.0", - "pydantic>=2.0.0", -] - -[build-system] -requires = ["uv_build>=0.9.22,<0.10.0"] -build-backend = "uv_build" diff --git a/packages/rag-client/src/rag_client/__init__.py b/packages/rag-client/src/rag_client/__init__.py deleted file mode 100644 index 2487365..0000000 --- a/packages/rag-client/src/rag_client/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -"""RAG client package for interacting with RAG services.""" - -from rag_client.base import ( - Message, - RAGRequest, - RAGResponse, - RAGServiceBase, -) -from rag_client.echo import EchoRAGService -from rag_client.http import HTTPRAGService - -__all__ = [ - "EchoRAGService", - "HTTPRAGService", - "Message", - "RAGRequest", - "RAGResponse", - "RAGServiceBase", -] diff --git a/packages/rag-client/src/rag_client/base.py b/packages/rag-client/src/rag_client/base.py deleted file mode 100644 index 8cb62eb..0000000 --- a/packages/rag-client/src/rag_client/base.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Base RAG service interface.""" - -from abc import ABC, abstractmethod -from types import TracebackType -from typing import Self - -from pydantic import BaseModel, Field - - -class Message(BaseModel): - """OpenAI-style message format.""" - - role: str = Field(..., description="Role: system, user, or assistant") - content: str = Field(..., description="Message content") - - -class RAGRequest(BaseModel): - """Request model for RAG endpoint.""" - - messages: list[Message] = Field(..., description="Conversation history") - - -class RAGResponse(BaseModel): - """Response model from RAG endpoint.""" - - response: str = Field(..., description="Generated response from RAG") - - -class RAGServiceBase(ABC): - """Abstract base class for RAG service implementations. - - Provides a common interface for different RAG service backends - (HTTP, mock, echo, etc.). - """ - - @abstractmethod - async def query(self, messages: list[dict[str, str]]) -> str: - """Send conversation history to RAG endpoint and get response. - - Args: - messages: OpenAI-style conversation history - e.g., [{"role": "user", "content": "Hello"}, ...] - - Returns: - Response string from RAG endpoint - - Raises: - Exception: Implementation-specific exceptions - - """ - ... - - @abstractmethod - async def close(self) -> None: - """Close the service and release resources.""" - ... - - async def __aenter__(self) -> Self: - """Async context manager entry.""" - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_val: BaseException | None, - exc_tb: TracebackType | None, - ) -> None: - """Async context manager exit.""" - await self.close() diff --git a/packages/rag-client/src/rag_client/echo.py b/packages/rag-client/src/rag_client/echo.py deleted file mode 100644 index 2dfde59..0000000 --- a/packages/rag-client/src/rag_client/echo.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Echo RAG service implementation for testing.""" - -import logging - -from rag_client.base import RAGServiceBase - -logger = logging.getLogger(__name__) - -# Error messages -_ERR_NO_MESSAGES = "No messages provided" -_ERR_NO_USER_MESSAGE = "No user message found in conversation history" - - -class EchoRAGService(RAGServiceBase): - """Echo RAG service that returns the last user message. - - Useful for testing and development without needing a real RAG endpoint. - Simply echoes back the content of the last user message with an optional prefix. - """ - - def __init__(self, prefix: str = "Echo: ") -> None: - """Initialize Echo RAG service. - - Args: - prefix: Prefix to add to echoed messages (default: "Echo: ") - - """ - self.prefix = prefix - logger.info("EchoRAGService initialized with prefix: %r", prefix) - - async def query(self, messages: list[dict[str, str]]) -> str: - """Echo back the last user message with a prefix. - - Args: - messages: OpenAI-style conversation history - e.g., [{"role": "user", "content": "Hello"}, ...] - - Returns: - The last user message content with prefix - - Raises: - ValueError: If no messages or no user messages found - - """ - if not messages: - raise ValueError(_ERR_NO_MESSAGES) - - # Find the last user message - last_user_message = None - for msg in reversed(messages): - if msg.get("role") == "user": - last_user_message = msg.get("content", "") - break - - if last_user_message is None: - raise ValueError(_ERR_NO_USER_MESSAGE) - - response = f"{self.prefix}{last_user_message}" - logger.debug("Echo response: %s", response) - return response - - async def close(self) -> None: - """Close the service (no-op for echo service).""" - logger.info("EchoRAGService closed") diff --git a/packages/rag-client/src/rag_client/http.py b/packages/rag-client/src/rag_client/http.py deleted file mode 100644 index e985893..0000000 --- a/packages/rag-client/src/rag_client/http.py +++ /dev/null @@ -1,115 +0,0 @@ -"""HTTP-based RAG service implementation.""" - -import logging - -import httpx - -from rag_client.base import Message, RAGRequest, RAGResponse, RAGServiceBase - -logger = logging.getLogger(__name__) - - -class HTTPRAGService(RAGServiceBase): - """HTTP-based RAG service with high concurrency support. - - Uses httpx AsyncClient with connection pooling for optimal performance - when handling multiple concurrent requests. - """ - - def __init__( - self, - endpoint_url: str, - max_connections: int = 100, - max_keepalive_connections: int = 20, - timeout: float = 30.0, - ) -> None: - """Initialize HTTP RAG service with connection pooling. - - Args: - endpoint_url: URL of the RAG endpoint - max_connections: Maximum number of concurrent connections - max_keepalive_connections: Maximum number of idle connections to keep alive - timeout: Request timeout in seconds - - """ - self.endpoint_url = endpoint_url - self.timeout = timeout - - # Configure connection limits for high concurrency - limits = httpx.Limits( - max_connections=max_connections, - max_keepalive_connections=max_keepalive_connections, - ) - - # Create async client with connection pooling - self._client = httpx.AsyncClient( - limits=limits, - timeout=httpx.Timeout(timeout), - http2=True, # Enable HTTP/2 for better performance - ) - - logger.info( - "HTTPRAGService initialized with endpoint: %s, " - "max_connections: %s, timeout: %ss", - self.endpoint_url, - max_connections, - timeout, - ) - - async def query(self, messages: list[dict[str, str]]) -> str: - """Send conversation history to RAG endpoint and get response. - - Args: - messages: OpenAI-style conversation history - e.g., [{"role": "user", "content": "Hello"}, ...] - - Returns: - Response string from RAG endpoint - - Raises: - httpx.HTTPError: If HTTP request fails - ValueError: If response format is invalid - - """ - try: - # Validate and construct request - message_objects = [Message(**msg) for msg in messages] - request = RAGRequest(messages=message_objects) - - # Make async HTTP POST request - logger.debug("Sending RAG request with %s messages", len(messages)) - - response = await self._client.post( - self.endpoint_url, - json=request.model_dump(), - headers={"Content-Type": "application/json"}, - ) - - # Raise exception for HTTP errors - response.raise_for_status() - - # Parse response - response_data = response.json() - rag_response = RAGResponse(**response_data) - - logger.debug("RAG response received: %s chars", len(rag_response.response)) - except httpx.HTTPStatusError as e: - logger.exception( - "HTTP error calling RAG endpoint: %s - %s", - e.response.status_code, - e.response.text, - ) - raise - except httpx.RequestError: - logger.exception("Request error calling RAG endpoint:") - raise - except Exception: - logger.exception("Unexpected error calling RAG endpoint") - raise - else: - return rag_response.response - - async def close(self) -> None: - """Close the HTTP client and release connections.""" - await self._client.aclose() - logger.info("HTTPRAGService client closed") diff --git a/packages/rag-client/src/rag_client/py.typed b/packages/rag-client/src/rag_client/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/pyproject.toml b/pyproject.toml index 8a63752..f5cfe53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "redis[hiredis]>=5.2.0", "tenacity>=9.0.0", "python-multipart>=0.0.12", - "rag-client", + "httpx>=0.27.0", ] [project.scripts] @@ -32,6 +32,7 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "fakeredis>=2.34.0", "inline-snapshot>=0.32.1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", @@ -43,19 +44,16 @@ dev = [ ] [tool.ruff] -exclude = ["tests"] +exclude = ["tests", "scripts"] [tool.ruff.lint] select = ['ALL'] ignore = ['D203', 'D213'] [tool.ty.src] -include = ["src", "packages"] +include = ["src"] exclude = ["tests"] -[tool.uv.sources] -rag-client = { workspace = true } - [tool.pytest.ini_options] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" @@ -67,6 +65,13 @@ addopts = [ "--cov-branch", ] +filterwarnings = [ + "ignore:Call to '__init__' function with deprecated usage:DeprecationWarning:fakeredis", + "ignore:.*retry_on_timeout.*:DeprecationWarning", + "ignore:.*lib_name.*:DeprecationWarning", + "ignore:.*lib_version.*:DeprecationWarning", +] + env = [ "FIRESTORE_EMULATOR_HOST=[::1]:8911", "GCP_PROJECT_ID=test-project", @@ -77,8 +82,3 @@ env = [ "REDIS_PORT=6379", "DLP_TEMPLATE_COMPLETE_FLOW=projects/test/dlpJobTriggers/test", ] - -[tool.uv.workspace] -members = [ - "packages/rag-client", -] diff --git a/scripts/load_test.py b/scripts/load_test.py new file mode 100755 index 0000000..711610a --- /dev/null +++ b/scripts/load_test.py @@ -0,0 +1,231 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "locust>=2.32.5", +# ] +# /// +"""Locust load testing script for capa-de-integracion service. + +Usage: + # Run with web UI (default port 8089) + uv run scripts/load_test.py + + # Run headless with specific users and spawn rate + uv run scripts/load_test.py --headless -u 100 -r 10 + + # Run against specific host + uv run scripts/load_test.py --host http://localhost:8080 + + # Run for specific duration + uv run scripts/load_test.py --headless -u 50 -r 5 --run-time 5m +""" + +import random +from locust import HttpUser, between, task + + +class ConversationUser(HttpUser): + """Simulate users interacting with the conversation API.""" + + wait_time = between(1, 3) # Wait 1-3 seconds between tasks + host = "http://localhost:8080" + + # Sample data for realistic load testing + phone_numbers = [ + f"555-{1000 + i:04d}" for i in range(100) + ] # 100 unique phone numbers + + conversation_messages = [ + "Hola", + "¿Cuál es mi saldo?", + "Necesito ayuda con mi tarjeta", + "¿Dónde está mi sucursal más cercana?", + "Quiero hacer una transferencia", + "¿Cómo puedo activar mi tarjeta?", + "Tengo un problema con mi cuenta", + "¿Cuáles son los horarios de atención?", + ] + + notification_messages = [ + "Tu tarjeta fue bloqueada por seguridad", + "Se detectó un cargo de $1,500 en tu cuenta", + "Tu préstamo fue aprobado", + "Transferencia recibida: $5,000", + "Recordatorio: Tu pago vence mañana", + ] + + screen_contexts = [ + "home", + "card_management", + "account_details", + "transfers", + "help_center", + ] + + def on_start(self): + """Called when a simulated user starts.""" + # Assign a phone number to this user for the session + self.phone = random.choice(self.phone_numbers) + self.nombre = f"Usuario_{self.phone.replace('-', '')}" + + @task(5) + def health_check(self): + """Health check endpoint - most frequent task.""" + with self.client.get("/health", catch_response=True) as response: + if response.status_code == 200: + data = response.json() + if data.get("status") == "healthy": + response.success() + else: + response.failure("Health check returned unhealthy status") + else: + response.failure(f"Got status code {response.status_code}") + + @task(10) + def detect_intent(self): + """Test the main conversation endpoint.""" + payload = { + "mensaje": random.choice(self.conversation_messages), + "usuario": { + "telefono": self.phone, + "nickname": self.nombre, + }, + "canal": "web", + "pantallaContexto": random.choice(self.screen_contexts), + } + + with self.client.post( + "/api/v1/dialogflow/detect-intent", + json=payload, + catch_response=True, + ) as response: + if response.status_code == 200: + data = response.json() + if "responseId" in data or "queryResult" in data: + response.success() + else: + response.failure("Response missing expected fields") + elif response.status_code == 400: + response.failure(f"Validation error: {response.text}") + elif response.status_code == 500: + response.failure(f"Internal server error: {response.text}") + else: + response.failure(f"Unexpected status code: {response.status_code}") + + @task(3) + def send_notification(self): + """Test the notification endpoint.""" + payload = { + "texto": random.choice(self.notification_messages), + "telefono": self.phone, + "parametrosOcultos": { + "transaction_id": f"TXN{random.randint(10000, 99999)}", + "amount": random.randint(100, 10000), + }, + } + + with self.client.post( + "/api/v1/dialogflow/notification", + json=payload, + catch_response=True, + ) as response: + if response.status_code == 200: + response.success() + elif response.status_code == 400: + response.failure(f"Validation error: {response.text}") + elif response.status_code == 500: + response.failure(f"Internal server error: {response.text}") + else: + response.failure(f"Unexpected status code: {response.status_code}") + + @task(4) + def quick_reply_screen(self): + """Test the quick reply screen endpoint.""" + payload = { + "usuario": { + "telefono": self.phone, + "nombre": self.nombre, + }, + "pantallaContexto": random.choice(self.screen_contexts), + } + + with self.client.post( + "/api/v1/quick-replies/screen", + json=payload, + catch_response=True, + ) as response: + if response.status_code == 200: + data = response.json() + if "responseId" in data and "quick_replies" in data: + response.success() + else: + response.failure("Response missing expected fields") + elif response.status_code == 400: + response.failure(f"Validation error: {response.text}") + elif response.status_code == 500: + response.failure(f"Internal server error: {response.text}") + else: + response.failure(f"Unexpected status code: {response.status_code}") + + +class ConversationFlowUser(HttpUser): + """Simulate realistic conversation flows with multiple interactions.""" + + wait_time = between(2, 5) + host = "http://localhost:8080" + weight = 2 # This user class will be 2x more likely to be chosen + + def on_start(self): + """Initialize user session.""" + self.phone = f"555-{random.randint(2000, 2999):04d}" + self.nombre = f"Flow_User_{random.randint(1000, 9999)}" + + @task + def complete_conversation_flow(self): + """Simulate a complete conversation flow.""" + # Step 1: Start with quick replies + screen_payload = { + "usuario": { + "telefono": self.phone, + "nombre": self.nombre, + }, + "pantallaContexto": "help_center", + } + self.client.post("/api/v1/quick-replies/screen", json=screen_payload) + + # Step 2: Have a conversation + conversation_steps = [ + "Hola, necesito ayuda", + "¿Cómo puedo verificar mi saldo?", + "Gracias por la información", + ] + + for mensaje in conversation_steps: + payload = { + "mensaje": mensaje, + "usuario": { + "telefono": self.phone, + "nickname": self.nombre, + }, + "canal": "mobile", + "pantallaContexto": "help_center", + } + self.client.post("/api/v1/dialogflow/detect-intent", json=payload) + # Small delay between messages + self.wait() + + +if __name__ == "__main__": + import os + import sys + + # Set default host if not provided via command line + if "--host" not in sys.argv and "HOST" not in os.environ: + os.environ["HOST"] = "http://localhost:8080" + + # Import and run locust + from locust import main as locust_main + + # Run locust with command line arguments + sys.exit(locust_main.main()) diff --git a/src/capa_de_integracion/dependencies.py b/src/capa_de_integracion/dependencies.py index 286d93a..e1bd5a1 100644 --- a/src/capa_de_integracion/dependencies.py +++ b/src/capa_de_integracion/dependencies.py @@ -16,6 +16,7 @@ from .services import ( QuickReplyContentService, ) from .services.firestore_service import FirestoreService +from .services.quick_reply_session_service import QuickReplySessionService from .services.redis_service import RedisService @@ -43,6 +44,16 @@ def get_quick_reply_content_service() -> QuickReplyContentService: return QuickReplyContentService(settings) +@lru_cache(maxsize=1) +def get_quick_reply_session_service() -> QuickReplySessionService: + """Get quick reply session service instance.""" + return QuickReplySessionService( + redis_service=get_redis_service(), + firestore_service=get_firestore_service(), + quick_reply_content_service=get_quick_reply_content_service(), + ) + + @lru_cache(maxsize=1) def get_notification_manager() -> NotificationManagerService: """Get notification manager instance.""" diff --git a/src/capa_de_integracion/models/conversation.py b/src/capa_de_integracion/models/conversation.py index 1669aee..1467858 100644 --- a/src/capa_de_integracion/models/conversation.py +++ b/src/capa_de_integracion/models/conversation.py @@ -12,6 +12,8 @@ class User(BaseModel): telefono: str = Field(..., min_length=1) nickname: str | None = None + model_config = {"extra": "ignore"} + class QueryResult(BaseModel): """Query result from Dialogflow.""" @@ -19,7 +21,7 @@ class QueryResult(BaseModel): response_text: str | None = Field(None, alias="responseText") parameters: dict[str, Any] | None = Field(None, alias="parameters") - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "ignore"} class DetectIntentResponse(BaseModel): @@ -29,7 +31,7 @@ class DetectIntentResponse(BaseModel): query_result: QueryResult | None = Field(None, alias="queryResult") quick_replies: Any | None = None # QuickReplyScreen from quick_replies module - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "ignore"} class ConversationRequest(BaseModel): @@ -40,7 +42,7 @@ class ConversationRequest(BaseModel): canal: str = Field(..., alias="canal") pantalla_contexto: str | None = Field(None, alias="pantallaContexto") - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "ignore"} class ConversationEntry(BaseModel): @@ -55,7 +57,7 @@ class ConversationEntry(BaseModel): parameters: dict[str, Any] | None = Field(None, alias="parameters") canal: str | None = Field(None, alias="canal") - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "ignore"} class ConversationSession(BaseModel): @@ -73,7 +75,7 @@ class ConversationSession(BaseModel): last_message: str | None = Field(None, alias="lastMessage") pantalla_contexto: str | None = Field(None, alias="pantallaContexto") - model_config = {"populate_by_name": True} + model_config = {"populate_by_name": True, "extra": "ignore"} @classmethod def create( diff --git a/src/capa_de_integracion/routers/quick_replies.py b/src/capa_de_integracion/routers/quick_replies.py index 2fb965d..35fdfff 100644 --- a/src/capa_de_integracion/routers/quick_replies.py +++ b/src/capa_de_integracion/routers/quick_replies.py @@ -2,20 +2,17 @@ import logging from typing import Annotated -from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from capa_de_integracion.dependencies import ( - get_firestore_service, - get_quick_reply_content_service, - get_redis_service, + get_quick_reply_session_service, ) from capa_de_integracion.models.quick_replies import QuickReplyScreen -from capa_de_integracion.services.firestore_service import FirestoreService -from capa_de_integracion.services.quick_reply_content import QuickReplyContentService -from capa_de_integracion.services.redis_service import RedisService +from capa_de_integracion.services.quick_reply_session_service import ( + QuickReplySessionService, +) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/quick-replies", tags=["quick-replies"]) @@ -47,11 +44,9 @@ class QuickReplyScreenResponse(BaseModel): @router.post("/screen") async def start_quick_reply_session( request: QuickReplyScreenRequest, - redis_service: Annotated[RedisService, Depends(get_redis_service)], - firestore_service: Annotated[FirestoreService, Depends(get_firestore_service)], - quick_reply_content_service: Annotated[QuickReplyContentService, Depends( - get_quick_reply_content_service, - )], + quick_reply_session_service: Annotated[ + QuickReplySessionService, Depends(get_quick_reply_session_service), + ], ) -> QuickReplyScreenResponse: """Start a quick reply FAQ session for a specific screen. @@ -60,52 +55,22 @@ async def start_quick_reply_session( Args: request: Quick reply screen request - redis_service: Redis service instance - firestore_service: Firestore service instance - quick_reply_content_service: Quick reply content service instance + quick_reply_session_service: Quick reply session service instance Returns: - Detect intent response with quick reply questions + Quick reply screen response with session ID and questions """ - def _validate_phone(phone: str) -> None: - if not phone or not phone.strip(): - msg = "Phone number is required" - raise ValueError(msg) - try: - telefono = request.usuario.telefono - pantalla_contexto = request.pantalla_contexto - _validate_phone(telefono) - - session = await firestore_service.get_session_by_phone(telefono) - if session: - session_id = session.session_id - await firestore_service.update_pantalla_contexto( - session_id, pantalla_contexto, - ) - session.pantalla_contexto = pantalla_contexto - else: - session_id = str(uuid4()) - user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}" - session = await firestore_service.create_session( - session_id, user_id, telefono, pantalla_contexto, - ) - - # Cache session - await redis_service.save_session(session) - logger.info( - "Created quick reply session %s for screen: %s", - session_id, - pantalla_contexto, + result = await quick_reply_session_service.start_quick_reply_session( + telefono=request.usuario.telefono, + _nombre=request.usuario.nombre, + pantalla_contexto=request.pantalla_contexto, ) - # Load quick replies - quick_replies = await quick_reply_content_service.get_quick_replies( - pantalla_contexto, - ) return QuickReplyScreenResponse( - responseId=session_id, quick_replies=quick_replies, + responseId=result.session_id, + quick_replies=result.quick_replies, ) except ValueError as e: diff --git a/src/capa_de_integracion/services/__init__.py b/src/capa_de_integracion/services/__init__.py index 5ee9703..41eae2b 100644 --- a/src/capa_de_integracion/services/__init__.py +++ b/src/capa_de_integracion/services/__init__.py @@ -4,10 +4,12 @@ from .conversation_manager import ConversationManagerService from .dlp_service import DLPService from .notification_manager import NotificationManagerService from .quick_reply_content import QuickReplyContentService +from .quick_reply_session_service import QuickReplySessionService __all__ = [ "ConversationManagerService", "DLPService", "NotificationManagerService", "QuickReplyContentService", + "QuickReplySessionService", ] diff --git a/src/capa_de_integracion/services/quick_reply_session_service.py b/src/capa_de_integracion/services/quick_reply_session_service.py new file mode 100644 index 0000000..833de75 --- /dev/null +++ b/src/capa_de_integracion/services/quick_reply_session_service.py @@ -0,0 +1,121 @@ +"""Quick reply session service for managing FAQ sessions.""" + +import logging +from uuid import uuid4 + +from capa_de_integracion.models.quick_replies import QuickReplyScreen +from capa_de_integracion.services.firestore_service import FirestoreService +from capa_de_integracion.services.quick_reply_content import QuickReplyContentService +from capa_de_integracion.services.redis_service import RedisService + +logger = logging.getLogger(__name__) + + +class QuickReplySessionResponse: + """Response from quick reply session service.""" + + def __init__(self, session_id: str, quick_replies: QuickReplyScreen) -> None: + """Initialize response. + + Args: + session_id: The session ID + quick_replies: The quick reply screen data + + """ + self.session_id = session_id + self.quick_replies = quick_replies + + +class QuickReplySessionService: + """Service for managing quick reply FAQ sessions.""" + + def __init__( + self, + redis_service: RedisService, + firestore_service: FirestoreService, + quick_reply_content_service: QuickReplyContentService, + ) -> None: + """Initialize quick reply session service. + + Args: + redis_service: Redis service instance + firestore_service: Firestore service instance + quick_reply_content_service: Quick reply content service instance + + """ + self.redis_service = redis_service + self.firestore_service = firestore_service + self.quick_reply_content_service = quick_reply_content_service + + def _validate_phone(self, phone: str) -> None: + """Validate phone number. + + Args: + phone: Phone number to validate + + Raises: + ValueError: If phone is empty or invalid + + """ + if not phone or not phone.strip(): + msg = "Phone number is required" + raise ValueError(msg) + + async def start_quick_reply_session( + self, + telefono: str, + _nombre: str, + pantalla_contexto: str, + ) -> QuickReplySessionResponse: + """Start a quick reply FAQ session for a specific screen. + + Creates or updates a conversation session with pantalla_contexto set, + loads the quick reply questions for the screen, and returns them. + + Args: + telefono: User's phone number + _nombre: User's name (currently unused but part of API contract) + pantalla_contexto: Screen context identifier + + Returns: + Quick reply session response with session ID and quick replies + + Raises: + ValueError: If validation fails or data is invalid + Exception: If there's an error creating session or loading content + + """ + self._validate_phone(telefono) + + # Get or create session + session = await self.firestore_service.get_session_by_phone(telefono) + if session: + session_id = session.session_id + await self.firestore_service.update_pantalla_contexto( + session_id, pantalla_contexto, + ) + session.pantalla_contexto = pantalla_contexto + else: + session_id = str(uuid4()) + user_id = f"user_by_phone_{telefono.replace(' ', '').replace('-', '')}" + session = await self.firestore_service.create_session( + session_id, user_id, telefono, pantalla_contexto, + ) + + # Cache session in Redis + await self.redis_service.save_session(session) + logger.info( + "Created quick reply session %s for screen: %s", + session_id, + pantalla_contexto, + ) + + # Load quick replies for the screen + quick_replies = await self.quick_reply_content_service.get_quick_replies( + pantalla_contexto, + ) + + return QuickReplySessionResponse( + session_id=session_id, + quick_replies=quick_replies, + ) diff --git a/tests/README.md b/tests/README.md index 23193f3..d9e31b5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,8 @@ This directory contains the test suite for the capa-de-integracion application. - **pytest-asyncio** - Async test support - **pytest-cov** - Coverage reporting - **pytest-env** - Environment variable configuration (cleaner than manual setup) -- **pytest-recording** - HTTP recording (configured but not used for gRPC Firestore) +- **pytest-recording** - HTTP recording (configured but not used for gRPC Firestore or Redis) +- **fakeredis** - In-memory Redis mock for testing without a container - **inline-snapshot** - Snapshot testing support ## Running Tests @@ -27,6 +28,18 @@ uv run pytest --cov=capa_de_integracion uv run pytest -v ``` +## Redis Service Tests + +The Redis service tests use **fakeredis**, an in-memory implementation of Redis that doesn't require a running Redis container. + +**Benefits:** +- ✅ No external dependencies - tests run anywhere +- ✅ Fast execution - all operations are in-memory +- ✅ Automatic cleanup - each test gets a fresh Redis instance +- ✅ Full Redis protocol support - tests verify real behavior + +The `redis_service` and `clean_redis` fixtures automatically use fakeredis, so tests work identically to production code but without needing a container. + ## Firestore Service Tests The Firestore service tests require the Firestore emulator to be running. @@ -59,19 +72,21 @@ The Firestore service tests require the Firestore emulator to be running. #### Why No pytest-recording Cassettes? -While pytest-recording is configured in the project, **cassettes are not generated** for Firestore tests. This is because: +While pytest-recording is configured in the project, **cassettes are not generated** for Firestore or Redis tests. This is because: - **Firestore uses gRPC protocol**, not HTTP +- **Redis uses RESP (Redis Serialization Protocol)**, not HTTP - **pytest-recording/vcrpy only supports HTTP** requests -- The Firestore Python client communicates via gRPC, which cannot be recorded by vcrpy -**Solution**: Tests run directly against the Firestore emulator. This provides: -- ✅ Real integration testing with actual Firestore behavior -- ✅ No mocking - tests verify actual data operations -- ✅ Fast execution (emulator is local) -- ❌ Requires emulator to be running +**Solutions:** +- **Redis**: Uses **fakeredis** - an in-memory Redis implementation that provides full Redis functionality without requiring a container or cassettes +- **Firestore**: Tests run directly against the Firestore emulator, providing: + - ✅ Real integration testing with actual Firestore behavior + - ✅ No mocking - tests verify actual data operations + - ✅ Fast execution (emulator is local) + - ❌ Requires emulator to be running -If you need offline/recorded tests, consider: +If you need offline/recorded Firestore tests, consider: 1. Using the emulator's export/import feature for test data 2. Implementing a mock FirestoreService for unit tests 3. Using snapshot testing with inline-snapshot for assertions @@ -97,13 +112,15 @@ env = GCP_LOCATION=us-central1 GCP_FIRESTORE_DATABASE_ID=(default) RAG_ENDPOINT_URL=http://localhost:8000/rag - REDIS_HOST=localhost - REDIS_PORT=6379 + REDIS_HOST=localhost # Not used - tests use fakeredis + REDIS_PORT=6379 # Not used - tests use fakeredis DLP_TEMPLATE_COMPLETE_FLOW=projects/test/dlpJobTriggers/test ``` These are automatically loaded before any test runs, ensuring consistent test environment setup. +**Note:** Redis tests use **fakeredis** instead of connecting to the configured REDIS_HOST/REDIS_PORT, so no Redis container is needed. + ## Fixtures ### `emulator_settings` diff --git a/tests/conftest.py b/tests/conftest.py index 4077295..c8039ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,11 @@ from collections.abc import AsyncGenerator import pytest import pytest_asyncio +from fakeredis import aioredis as fakeredis from capa_de_integracion.config import Settings from capa_de_integracion.services.firestore_service import FirestoreService +from capa_de_integracion.services.redis_service import RedisService # Configure pytest-asyncio pytest_plugins = ("pytest_asyncio",) @@ -65,6 +67,51 @@ async def _cleanup_collections(service: FirestoreService) -> None: await doc.reference.delete() +@pytest_asyncio.fixture +async def redis_service( + emulator_settings: Settings, +) -> AsyncGenerator[RedisService, None]: + """Create RedisService instance with fakeredis for testing.""" + service = RedisService(emulator_settings) + # Use fakeredis instead of connecting to a real Redis instance + service.redis = await fakeredis.FakeRedis(decode_responses=True) + + yield service + + # Cleanup: Close the service + await service.close() + + +@pytest_asyncio.fixture +async def clean_redis(redis_service: RedisService) -> AsyncGenerator[RedisService, None]: + """Provide a clean Redis service and cleanup after test.""" + # Cleanup before test + await _cleanup_redis(redis_service) + + yield redis_service + + # Cleanup after test + await _cleanup_redis(redis_service) + + +async def _cleanup_redis(service: RedisService) -> None: + """Delete all keys from Redis.""" + if service.redis: + # Delete all keys matching our patterns + patterns = [ + "conversation:*", + "notification:*", + ] + for pattern in patterns: + cursor = 0 + while True: + cursor, keys = await service.redis.scan(cursor, match=pattern, count=100) + if keys: + await service.redis.delete(*keys) + if cursor == 0: + break + + def pytest_recording_configure(config, vcr): """Configure pytest-recording for Firestore emulator.""" # Don't filter requests to the emulator diff --git a/tests/services/test_dlp_service.py b/tests/services/test_dlp_service.py new file mode 100644 index 0000000..eedc148 --- /dev/null +++ b/tests/services/test_dlp_service.py @@ -0,0 +1,216 @@ +"""Tests for DLPService.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from google.cloud.dlp_v2 import types + +from capa_de_integracion.config import Settings +from capa_de_integracion.services.dlp_service import DLPService + + +@pytest.fixture +def mock_settings(): + """Create mock settings for testing.""" + settings = Mock(spec=Settings) + settings.gcp_project_id = "test-project" + settings.gcp_location = "us-central1" + return settings + + +@pytest.fixture +def service(mock_settings): + """Create DLPService instance with mocked client.""" + with patch("capa_de_integracion.services.dlp_service.dlp_v2.DlpServiceAsyncClient"): + return DLPService(mock_settings) + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_no_findings(service): + """Test get_obfuscated_string with no findings.""" + mock_response = Mock() + mock_response.result.findings = [] + + service.dlp_client.inspect_content = AsyncMock(return_value=mock_response) + + text = "This is a safe text" + result = await service.get_obfuscated_string(text, "test-template") + + assert result == text + service.dlp_client.inspect_content.assert_called_once() + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_with_credit_card(service): + """Test get_obfuscated_string obfuscates credit card.""" + # Create mock finding + finding = Mock() + finding.quote = "4532123456789012" + finding.info_type.name = "CREDIT_CARD_NUMBER" + finding.likelihood.value = 4 # LIKELY (above threshold) + + mock_response = Mock() + mock_response.result.findings = [finding] + + service.dlp_client.inspect_content = AsyncMock(return_value=mock_response) + + text = "My card number is 4532123456789012" + result = await service.get_obfuscated_string(text, "test-template") + + assert "**** **** **** 9012" in result + assert "4532123456789012" not in result + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_with_email(service): + """Test get_obfuscated_string obfuscates email.""" + finding = Mock() + finding.quote = "user@example.com" + finding.info_type.name = "EMAIL_ADDRESS" + finding.likelihood.value = 5 # VERY_LIKELY + + mock_response = Mock() + mock_response.result.findings = [finding] + + service.dlp_client.inspect_content = AsyncMock(return_value=mock_response) + + text = "Contact me at user@example.com" + result = await service.get_obfuscated_string(text, "test-template") + + assert "[CORREO]" in result + assert "user@example.com" not in result + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_with_phone(service): + """Test get_obfuscated_string obfuscates phone number.""" + finding = Mock() + finding.quote = "555-1234" + finding.info_type.name = "PHONE_NUMBER" + finding.likelihood.value = 4 + + mock_response = Mock() + mock_response.result.findings = [finding] + + service.dlp_client.inspect_content = AsyncMock(return_value=mock_response) + + text = "Call me at 555-1234" + result = await service.get_obfuscated_string(text, "test-template") + + assert "[TELEFONO]" in result + assert "555-1234" not in result + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_filters_low_likelihood(service): + """Test that findings below likelihood threshold are ignored.""" + finding = Mock() + finding.quote = "maybe@test.com" + finding.info_type.name = "EMAIL_ADDRESS" + finding.likelihood.value = 2 # UNLIKELY (below threshold of 3) + + mock_response = Mock() + mock_response.result.findings = [finding] + + service.dlp_client.inspect_content = AsyncMock(return_value=mock_response) + + text = "Email: maybe@test.com" + result = await service.get_obfuscated_string(text, "test-template") + + # Should not be obfuscated due to low likelihood + assert result == text + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_handles_direccion(service): + """Test get_obfuscated_string handles multiple DIRECCION tags.""" + findings = [] + for info_type in ["DIRECCION", "DIR_COLONIA", "DIR_CP"]: + finding = Mock() + finding.quote = f"test_{info_type}" + finding.info_type.name = info_type + finding.likelihood.value = 4 + findings.append(finding) + + mock_response = Mock() + mock_response.result.findings = findings + + service.dlp_client.inspect_content = AsyncMock(return_value=mock_response) + + text = "Address: test_DIRECCION, test_DIR_COLONIA, test_DIR_CP" + result = await service.get_obfuscated_string(text, "test-template") + + # Multiple [DIRECCION] tags should be cleaned up + assert result == "Address: [DIRECCION]" + + +@pytest.mark.asyncio +async def test_get_obfuscated_string_error_returns_original(service): + """Test that errors return original text.""" + service.dlp_client.inspect_content = AsyncMock( + side_effect=Exception("DLP API error"), + ) + + text = "Original text" + result = await service.get_obfuscated_string(text, "test-template") + + assert result == text + + +@pytest.mark.asyncio +async def test_close(service): + """Test close method.""" + service.dlp_client.transport.close = AsyncMock() + + await service.close() + + service.dlp_client.transport.close.assert_called_once() + + +def test_get_last4_normal(service): + """Test _get_last4 with normal input.""" + assert service._get_last4("1234567890") == "7890" + assert service._get_last4("1234 5678 9012 3456") == "3456" + + +def test_get_last4_short(service): + """Test _get_last4 with short input.""" + assert service._get_last4("123") == "123" + assert service._get_last4("12") == "12" + + +def test_get_last4_empty(service): + """Test _get_last4 with empty input.""" + assert service._get_last4("") == "" + assert service._get_last4(" ") == "" + + +def test_clean_direccion(service): + """Test _clean_direccion method.""" + assert service._clean_direccion("[DIRECCION], [DIRECCION]") == "[DIRECCION]" + assert service._clean_direccion("[DIRECCION] [DIRECCION]") == "[DIRECCION]" + assert service._clean_direccion("[DIRECCION], [DIRECCION], [DIRECCION]") == "[DIRECCION]" + assert service._clean_direccion("Text [DIRECCION] more text") == "Text [DIRECCION] more text" + + +def test_get_replacement(service): + """Test _get_replacement method.""" + assert service._get_replacement("EMAIL_ADDRESS", "test@example.com") == "[CORREO]" + assert service._get_replacement("PERSON_NAME", "John Doe") == "[NOMBRE]" + assert service._get_replacement("CVV_NUMBER", "123") == "[CVV]" + assert service._get_replacement("NIP", "1234") == "[NIP]" + assert service._get_replacement("SALDO", "1000.00") == "[SALDO]" + assert service._get_replacement("CLABE_INTERBANCARIA", "012345678901234567") == "[CLABE]" + assert service._get_replacement("UNKNOWN_TYPE", "value") is None + + +def test_get_replacement_credit_card(service): + """Test _get_replacement for credit card.""" + result = service._get_replacement("CREDIT_CARD_NUMBER", "4532 1234 5678 9012") + assert result == "**** **** **** 9012" + + +def test_get_replacement_cuenta(service): + """Test _get_replacement for account number.""" + result = service._get_replacement("CUENTA", "12345678901234") + assert result == "**************1234" diff --git a/tests/services/test_firestore_service.py b/tests/services/test_firestore_service.py index 7c134cc..3be4280 100644 --- a/tests/services/test_firestore_service.py +++ b/tests/services/test_firestore_service.py @@ -635,14 +635,16 @@ class TestErrorHandling: async def mock_stream(): mock_entry = MagicMock() - mock_entry.reference = AsyncMock() + mock_reference = MagicMock() + mock_reference.delete = AsyncMock() + mock_entry.reference = mock_reference yield mock_entry mock_collection.stream.return_value = mock_stream() - mock_doc_ref = AsyncMock() + mock_doc_ref = MagicMock() mock_doc_ref.collection.return_value = mock_collection - mock_doc_ref.delete.side_effect = Exception("Database error") + mock_doc_ref.delete = AsyncMock(side_effect=Exception("Database error")) original_session_ref = clean_firestore._session_ref clean_firestore._session_ref = MagicMock(return_value=mock_doc_ref) diff --git a/tests/services/test_notification_manager.py b/tests/services/test_notification_manager.py new file mode 100644 index 0000000..74c8ad1 --- /dev/null +++ b/tests/services/test_notification_manager.py @@ -0,0 +1,159 @@ +"""Tests for NotificationManagerService.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from capa_de_integracion.config import Settings +from capa_de_integracion.models.notification import ExternalNotificationRequest +from capa_de_integracion.services.dlp_service import DLPService +from capa_de_integracion.services.firestore_service import FirestoreService +from capa_de_integracion.services.notification_manager import NotificationManagerService +from capa_de_integracion.services.redis_service import RedisService + + +@pytest.fixture +def mock_settings(): + """Create mock settings.""" + settings = Mock(spec=Settings) + settings.dlp_template_complete_flow = "test-template" + return settings + + +@pytest.fixture +def mock_redis(): + """Create mock Redis service.""" + redis = Mock(spec=RedisService) + redis.save_or_append_notification = AsyncMock() + return redis + + +@pytest.fixture +def mock_firestore(): + """Create mock Firestore service.""" + firestore = Mock(spec=FirestoreService) + firestore.save_or_append_notification = AsyncMock() + return firestore + + +@pytest.fixture +def mock_dlp(): + """Create mock DLP service.""" + dlp = Mock(spec=DLPService) + dlp.get_obfuscated_string = AsyncMock(return_value="Obfuscated text") + return dlp + + +@pytest.fixture +def service(mock_settings, mock_redis, mock_firestore, mock_dlp): + """Create NotificationManagerService instance.""" + return NotificationManagerService( + settings=mock_settings, + redis_service=mock_redis, + firestore_service=mock_firestore, + dlp_service=mock_dlp, + ) + + +@pytest.mark.asyncio +async def test_process_notification_basic(service, mock_redis, mock_dlp): + """Test basic notification processing.""" + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Your card was blocked", + parametros_ocultos=None, + ) + + await service.process_notification(request) + + # Verify DLP was called + mock_dlp.get_obfuscated_string.assert_called_once_with( + "Your card was blocked", + "test-template", + ) + + # Verify Redis save was called + mock_redis.save_or_append_notification.assert_called_once() + call_args = mock_redis.save_or_append_notification.call_args + notification = call_args[0][0] + + assert notification.telefono == "555-1234" + assert notification.texto == "Obfuscated text" + assert notification.status == "active" + assert notification.nombre_evento_dialogflow == "notificacion" + + +@pytest.mark.asyncio +async def test_process_notification_with_parameters(service, mock_redis, mock_dlp): + """Test notification processing with hidden parameters.""" + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Transaction alert", + parametros_ocultos={ + "amount": "100.00", + "merchant": "Store ABC", + }, + ) + + await service.process_notification(request) + + # Verify Redis save was called + mock_redis.save_or_append_notification.assert_called_once() + notification = mock_redis.save_or_append_notification.call_args[0][0] + + # Verify parameters have prefix + assert "notification_po_amount" in notification.parametros + assert notification.parametros["notification_po_amount"] == "100.00" + assert "notification_po_merchant" in notification.parametros + assert notification.parametros["notification_po_merchant"] == "Store ABC" + + +@pytest.mark.asyncio +async def test_process_notification_firestore_async(service, mock_redis, mock_firestore): + """Test that Firestore save is asynchronous (fire-and-forget).""" + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Test notification", + parametros_ocultos=None, + ) + + await service.process_notification(request) + + # Redis should be called immediately + mock_redis.save_or_append_notification.assert_called_once() + + # Firestore may or may not be called yet (it's async) + # We can't easily test the fire-and-forget behavior without waiting + + +@pytest.mark.asyncio +async def test_process_notification_empty_parameters(service, mock_redis): + """Test notification processing with empty parameters.""" + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Test", + parametros_ocultos={}, + ) + + await service.process_notification(request) + + notification = mock_redis.save_or_append_notification.call_args[0][0] + assert notification.parametros == {} + + +@pytest.mark.asyncio +async def test_process_notification_generates_unique_id(service, mock_redis): + """Test that each notification gets a unique ID.""" + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Test", + parametros_ocultos=None, + ) + + await service.process_notification(request) + notification1 = mock_redis.save_or_append_notification.call_args[0][0] + + await service.process_notification(request) + notification2 = mock_redis.save_or_append_notification.call_args[0][0] + + assert notification1.id_notificacion != notification2.id_notificacion diff --git a/tests/services/test_quick_reply_content.py b/tests/services/test_quick_reply_content.py new file mode 100644 index 0000000..805bdb4 --- /dev/null +++ b/tests/services/test_quick_reply_content.py @@ -0,0 +1,170 @@ +"""Tests for QuickReplyContentService.""" + +import json +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from capa_de_integracion.config import Settings +from capa_de_integracion.models.quick_replies import QuickReplyScreen +from capa_de_integracion.services.quick_reply_content import QuickReplyContentService + + +@pytest.fixture +def mock_settings(): + """Create mock settings for testing.""" + settings = Mock(spec=Settings) + settings.base_path = Path("/tmp/test_resources") + return settings + + +@pytest.fixture +def service(mock_settings): + """Create QuickReplyContentService instance.""" + return QuickReplyContentService(mock_settings) + + +@pytest.mark.asyncio +async def test_get_quick_replies_empty_screen_id(service): + """Test get_quick_replies with empty screen_id.""" + result = await service.get_quick_replies("") + + assert isinstance(result, QuickReplyScreen) + assert result.header == "empty" + assert result.body is None + assert result.button is None + assert result.header_section is None + assert result.preguntas == [] + + +@pytest.mark.asyncio +async def test_get_quick_replies_none_screen_id(service): + """Test get_quick_replies with None screen_id.""" + result = await service.get_quick_replies(None) + + assert isinstance(result, QuickReplyScreen) + assert result.header == "empty" + assert result.preguntas == [] + + +@pytest.mark.asyncio +async def test_get_quick_replies_whitespace_screen_id(service): + """Test get_quick_replies with whitespace screen_id.""" + result = await service.get_quick_replies(" ") + + assert isinstance(result, QuickReplyScreen) + assert result.header == "empty" + assert result.preguntas == [] + + +@pytest.mark.asyncio +async def test_get_quick_replies_file_not_found(service, tmp_path): + """Test get_quick_replies raises error when file not found.""" + # Set service to use a temp directory where the file won't exist + service.quick_replies_path = tmp_path / "nonexistent_dir" + + with pytest.raises(ValueError, match="Error loading quick replies"): + await service.get_quick_replies("nonexistent") + + +@pytest.mark.asyncio +async def test_get_quick_replies_success(service, tmp_path): + """Test get_quick_replies successfully loads file.""" + # Create test JSON file + quick_replies_dir = tmp_path / "quick_replies" + quick_replies_dir.mkdir() + service.quick_replies_path = quick_replies_dir + + test_data = { + "header": "Test Header", + "body": "Test Body", + "button": "Test Button", + "header_section": "Test Section", + "preguntas": [ + { + "titulo": "Question 1", + "descripcion": "Description 1", + "respuesta": "Answer 1", + }, + { + "titulo": "Question 2", + "respuesta": "Answer 2", + }, + ], + } + + test_file = quick_replies_dir / "test_screen.json" + test_file.write_text(json.dumps(test_data), encoding="utf-8") + + result = await service.get_quick_replies("test_screen") + + assert isinstance(result, QuickReplyScreen) + assert result.header == "Test Header" + assert result.body == "Test Body" + assert result.button == "Test Button" + assert result.header_section == "Test Section" + assert len(result.preguntas) == 2 + assert result.preguntas[0].titulo == "Question 1" + assert result.preguntas[0].descripcion == "Description 1" + assert result.preguntas[0].respuesta == "Answer 1" + assert result.preguntas[1].titulo == "Question 2" + assert result.preguntas[1].descripcion is None + assert result.preguntas[1].respuesta == "Answer 2" + + +@pytest.mark.asyncio +async def test_get_quick_replies_invalid_json(service, tmp_path): + """Test get_quick_replies raises error for invalid JSON.""" + quick_replies_dir = tmp_path / "quick_replies" + quick_replies_dir.mkdir() + service.quick_replies_path = quick_replies_dir + + test_file = quick_replies_dir / "invalid.json" + test_file.write_text("{ invalid json }", encoding="utf-8") + + with pytest.raises(ValueError, match="Invalid JSON format"): + await service.get_quick_replies("invalid") + + +@pytest.mark.asyncio +async def test_get_quick_replies_minimal_data(service, tmp_path): + """Test get_quick_replies with minimal data.""" + quick_replies_dir = tmp_path / "quick_replies" + quick_replies_dir.mkdir() + service.quick_replies_path = quick_replies_dir + + test_data = { + "preguntas": [], + } + + test_file = quick_replies_dir / "minimal.json" + test_file.write_text(json.dumps(test_data), encoding="utf-8") + + result = await service.get_quick_replies("minimal") + + assert isinstance(result, QuickReplyScreen) + assert result.header is None + assert result.body is None + assert result.button is None + assert result.header_section is None + assert result.preguntas == [] + + +@pytest.mark.asyncio +async def test_validate_file_exists(service, tmp_path): + """Test _validate_file with existing file.""" + test_file = tmp_path / "test.json" + test_file.write_text("{}", encoding="utf-8") + + # Should not raise + service._validate_file(test_file, "test") + + +@pytest.mark.asyncio +async def test_validate_file_not_exists(service, tmp_path): + """Test _validate_file with non-existing file.""" + test_file = tmp_path / "nonexistent.json" + + with pytest.raises(ValueError, match="Quick reply file not found"): + service._validate_file(test_file, "test") diff --git a/tests/services/test_rag_services.py b/tests/services/test_rag_services.py new file mode 100644 index 0000000..f8e4f83 --- /dev/null +++ b/tests/services/test_rag_services.py @@ -0,0 +1,251 @@ +"""Tests for RAG services.""" + +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest + +from capa_de_integracion.services.rag import ( + EchoRAGService, + HTTPRAGService, + RAGServiceBase, +) +from capa_de_integracion.services.rag.base import Message, RAGRequest, RAGResponse + + +class TestEchoRAGService: + """Tests for EchoRAGService.""" + + @pytest.mark.asyncio + async def test_echo_default_prefix(self): + """Test echo service with default prefix.""" + service = EchoRAGService() + messages = [{"role": "user", "content": "Hello"}] + + response = await service.query(messages) + + assert response == "Echo: Hello" + + @pytest.mark.asyncio + async def test_echo_custom_prefix(self): + """Test echo service with custom prefix.""" + service = EchoRAGService(prefix="Bot: ") + messages = [{"role": "user", "content": "Hello"}] + + response = await service.query(messages) + + assert response == "Bot: Hello" + + @pytest.mark.asyncio + async def test_echo_multiple_messages(self): + """Test echo service returns last user message.""" + service = EchoRAGService() + messages = [ + {"role": "user", "content": "First message"}, + {"role": "assistant", "content": "Response"}, + {"role": "user", "content": "Last message"}, + ] + + response = await service.query(messages) + + assert response == "Echo: Last message" + + @pytest.mark.asyncio + async def test_echo_mixed_roles(self): + """Test echo service finds last user message among mixed roles.""" + service = EchoRAGService() + messages = [ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "User question"}, + {"role": "assistant", "content": "Assistant response"}, + ] + + response = await service.query(messages) + + assert response == "Echo: User question" + + @pytest.mark.asyncio + async def test_echo_no_messages_error(self): + """Test echo service raises error when no messages provided.""" + service = EchoRAGService() + + with pytest.raises(ValueError, match="No messages provided"): + await service.query([]) + + @pytest.mark.asyncio + async def test_echo_no_user_message_error(self): + """Test echo service raises error when no user message found.""" + service = EchoRAGService() + messages = [ + {"role": "system", "content": "System"}, + {"role": "assistant", "content": "Assistant"}, + ] + + with pytest.raises(ValueError, match="No user message found"): + await service.query(messages) + + @pytest.mark.asyncio + async def test_echo_close(self): + """Test echo service close method.""" + service = EchoRAGService() + await service.close() # Should not raise + + @pytest.mark.asyncio + async def test_echo_context_manager(self): + """Test echo service as async context manager.""" + async with EchoRAGService() as service: + messages = [{"role": "user", "content": "Test"}] + response = await service.query(messages) + assert response == "Echo: Test" + + +class TestHTTPRAGService: + """Tests for HTTPRAGService.""" + + @pytest.mark.asyncio + async def test_http_successful_query(self): + """Test HTTP RAG service successful query.""" + mock_response = Mock() + mock_response.json.return_value = {"response": "AI response"} + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client_class.return_value = mock_client + + service = HTTPRAGService( + endpoint_url="http://test.example.com/rag", + max_connections=10, + max_keepalive_connections=5, + timeout=15.0, + ) + + messages = [{"role": "user", "content": "Hello"}] + response = await service.query(messages) + + assert response == "AI response" + mock_client.post.assert_called_once() + call_kwargs = mock_client.post.call_args.kwargs + assert call_kwargs["json"]["messages"][0]["role"] == "user" + assert call_kwargs["json"]["messages"][0]["content"] == "Hello" + + @pytest.mark.asyncio + async def test_http_status_error(self): + """Test HTTP RAG service handles HTTP status errors.""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.post = AsyncMock( + side_effect=httpx.HTTPStatusError( + "Error", request=Mock(), response=mock_response, + ), + ) + mock_client_class.return_value = mock_client + + service = HTTPRAGService(endpoint_url="http://test.example.com/rag") + messages = [{"role": "user", "content": "Hello"}] + + with pytest.raises(httpx.HTTPStatusError): + await service.query(messages) + + @pytest.mark.asyncio + async def test_http_request_error(self): + """Test HTTP RAG service handles request errors.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.post = AsyncMock( + side_effect=httpx.RequestError("Connection failed", request=Mock()), + ) + mock_client_class.return_value = mock_client + + service = HTTPRAGService(endpoint_url="http://test.example.com/rag") + messages = [{"role": "user", "content": "Hello"}] + + with pytest.raises(httpx.RequestError): + await service.query(messages) + + @pytest.mark.asyncio + async def test_http_close(self): + """Test HTTP RAG service close method.""" + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.aclose = AsyncMock() + mock_client_class.return_value = mock_client + + service = HTTPRAGService(endpoint_url="http://test.example.com/rag") + await service.close() + + mock_client.aclose.assert_called_once() + + @pytest.mark.asyncio + async def test_http_context_manager(self): + """Test HTTP RAG service as async context manager.""" + mock_response = Mock() + mock_response.json.return_value = {"response": "AI response"} + mock_response.raise_for_status = Mock() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.post = AsyncMock(return_value=mock_response) + mock_client.aclose = AsyncMock() + mock_client_class.return_value = mock_client + + async with HTTPRAGService(endpoint_url="http://test.example.com/rag") as service: + messages = [{"role": "user", "content": "Test"}] + response = await service.query(messages) + assert response == "AI response" + + mock_client.aclose.assert_called_once() + + +class TestRAGModels: + """Tests for RAG data models.""" + + def test_message_model(self): + """Test Message model.""" + msg = Message(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + + def test_rag_request_model(self): + """Test RAGRequest model.""" + messages = [ + Message(role="user", content="Hello"), + Message(role="assistant", content="Hi"), + ] + request = RAGRequest(messages=messages) + assert len(request.messages) == 2 + assert request.messages[0].role == "user" + + def test_rag_response_model(self): + """Test RAGResponse model.""" + response = RAGResponse(response="AI response") + assert response.response == "AI response" + + +class TestRAGServiceBase: + """Tests for RAGServiceBase abstract methods.""" + + @pytest.mark.asyncio + async def test_base_context_manager_calls_close(self): + """Test that context manager calls close.""" + + class MockRAGService(RAGServiceBase): + def __init__(self): + self.closed = False + + async def query(self, messages): + return "test" + + async def close(self): + self.closed = True + + service = MockRAGService() + async with service: + pass + + assert service.closed is True diff --git a/tests/services/test_redis_service.py b/tests/services/test_redis_service.py new file mode 100644 index 0000000..a9a5460 --- /dev/null +++ b/tests/services/test_redis_service.py @@ -0,0 +1,928 @@ +"""Tests for RedisService.""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock + +import pytest +from inline_snapshot import snapshot + +from capa_de_integracion.config import Settings +from capa_de_integracion.models import ConversationEntry, ConversationSession +from capa_de_integracion.models.notification import Notification, NotificationSession +from capa_de_integracion.services.redis_service import RedisService + + +class TestConnectionManagement: + """Tests for Redis connection management.""" + + async def test_connect_and_close(self, emulator_settings: Settings) -> None: + """Test connecting to and closing Redis.""" + service = RedisService(emulator_settings) + + # Initially not connected + assert service.redis is None + + # Connect + await service.connect() + assert service.redis is not None + + # Close + await service.close() + + async def test_close_when_not_connected(self, emulator_settings: Settings) -> None: + """Test closing Redis when not connected does not raise error.""" + service = RedisService(emulator_settings) + + # Initially not connected + assert service.redis is None + + # Close should not raise error + await service.close() + + async def test_settings_initialization(self, emulator_settings: Settings) -> None: + """Test RedisService initializes with correct settings.""" + service = RedisService(emulator_settings) + + assert service.settings == emulator_settings + assert service.session_ttl == 2592000 # 30 days + assert service.notification_ttl == 2592000 # 30 days + assert service.qr_session_ttl == 86400 # 24 hours + + +class TestSessionManagement: + """Tests for conversation session management in Redis.""" + + async def test_save_and_get_session_by_id(self, clean_redis: RedisService) -> None: + """Test saving and retrieving a session by session ID.""" + session = ConversationSession.create( + session_id="test-session-1", + user_id="user-123", + telefono="+1234567890", + pantalla_contexto="home_screen", + last_message="Hello", + ) + + # Save session + success = await clean_redis.save_session(session) + assert success is True + + # Retrieve by session ID + retrieved = await clean_redis.get_session("test-session-1") + assert retrieved is not None + assert retrieved.session_id == "test-session-1" + assert retrieved.user_id == "user-123" + assert retrieved.telefono == "+1234567890" + assert retrieved.pantalla_contexto == "home_screen" + assert retrieved.last_message == "Hello" + + async def test_save_and_get_session_by_phone(self, clean_redis: RedisService) -> None: + """Test saving and retrieving a session by phone number.""" + session = ConversationSession.create( + session_id="test-session-2", + user_id="user-456", + telefono="+9876543210", + pantalla_contexto="settings", + ) + + # Save session + await clean_redis.save_session(session) + + # Retrieve by phone number (should use phone-to-session mapping) + retrieved = await clean_redis.get_session("+9876543210") + assert retrieved is not None + assert retrieved.session_id == "test-session-2" + assert retrieved.telefono == "+9876543210" + + async def test_get_session_not_found(self, clean_redis: RedisService) -> None: + """Test retrieving a non-existent session returns None.""" + session = await clean_redis.get_session("nonexistent-session") + assert session is None + + async def test_save_session_updates_existing(self, clean_redis: RedisService) -> None: + """Test saving a session updates existing session.""" + session = ConversationSession.create( + session_id="test-session-3", + user_id="user-789", + telefono="+5555555555", + last_message="Original message", + ) + + # Save initial session + await clean_redis.save_session(session) + + # Update and save again + session.last_message = "Updated message" + session.pantalla_contexto = "new_screen" + await clean_redis.save_session(session) + + # Retrieve and verify + retrieved = await clean_redis.get_session("test-session-3") + assert retrieved is not None + assert retrieved.last_message == "Updated message" + assert retrieved.pantalla_contexto == "new_screen" + + async def test_delete_session(self, clean_redis: RedisService) -> None: + """Test deleting a session.""" + session = ConversationSession.create( + session_id="test-session-4", + user_id="user-101", + telefono="+2222222222", + ) + + # Save and verify + await clean_redis.save_session(session) + assert await clean_redis.exists("test-session-4") is True + + # Delete + success = await clean_redis.delete_session("test-session-4") + assert success is True + + # Verify deletion + assert await clean_redis.exists("test-session-4") is False + retrieved = await clean_redis.get_session("test-session-4") + assert retrieved is None + + async def test_delete_nonexistent_session(self, clean_redis: RedisService) -> None: + """Test deleting a non-existent session returns False.""" + success = await clean_redis.delete_session("nonexistent-session") + assert success is False + + async def test_exists_session(self, clean_redis: RedisService) -> None: + """Test checking if session exists.""" + session = ConversationSession.create( + session_id="test-session-5", + user_id="user-202", + telefono="+3333333333", + ) + + # Should not exist initially + assert await clean_redis.exists("test-session-5") is False + + # Save and check again + await clean_redis.save_session(session) + assert await clean_redis.exists("test-session-5") is True + + async def test_phone_to_session_mapping(self, clean_redis: RedisService) -> None: + """Test that phone-to-session mapping is created and used.""" + session = ConversationSession.create( + session_id="test-session-6", + user_id="user-303", + telefono="+4444444444", + ) + + # Save session + await clean_redis.save_session(session) + + # Verify phone mapping key exists in Redis + assert clean_redis.redis is not None + phone_key = clean_redis._phone_to_session_key("+4444444444") + mapped_session_id = await clean_redis.redis.get(phone_key) + assert mapped_session_id == "test-session-6" + + async def test_get_session_deserialization_error( + self, clean_redis: RedisService, + ) -> None: + """Test get_session handles deserialization errors gracefully.""" + # Manually insert invalid JSON + assert clean_redis.redis is not None + key = clean_redis._session_key("invalid-session") + await clean_redis.redis.set(key, "invalid json data") + + # Should return None on deserialization error + session = await clean_redis.get_session("invalid-session") + assert session is None + + +class TestMessageManagement: + """Tests for conversation message management in Redis.""" + + async def test_save_and_get_messages(self, clean_redis: RedisService) -> None: + """Test saving and retrieving conversation messages.""" + session_id = "test-session-7" + + # Create messages + message1 = ConversationEntry( + timestamp=datetime(2024, 1, 1, 10, 0, 0, tzinfo=UTC), + entity="user", + type="CONVERSACION", + text="First message", + ) + message2 = ConversationEntry( + timestamp=datetime(2024, 1, 1, 10, 1, 0, tzinfo=UTC), + entity="assistant", + type="CONVERSACION", + text="First response", + ) + + # Save messages + success1 = await clean_redis.save_message(session_id, message1) + success2 = await clean_redis.save_message(session_id, message2) + assert success1 is True + assert success2 is True + + # Retrieve messages + messages = await clean_redis.get_messages(session_id) + assert len(messages) == 2 + + # Use inline-snapshot to verify structure + assert messages[0]["entity"] == snapshot("user") + assert messages[0]["type"] == snapshot("CONVERSACION") + assert messages[0]["text"] == snapshot("First message") + + assert messages[1]["entity"] == snapshot("assistant") + assert messages[1]["text"] == snapshot("First response") + + async def test_get_messages_empty_session(self, clean_redis: RedisService) -> None: + """Test retrieving messages from session with no messages.""" + messages = await clean_redis.get_messages("nonexistent-session") + assert messages == [] + + async def test_messages_ordered_by_timestamp(self, clean_redis: RedisService) -> None: + """Test that messages are returned in chronological order.""" + session_id = "test-session-8" + + # Create messages with different timestamps + messages_to_save = [ + ConversationEntry( + timestamp=datetime(2024, 1, 1, 10, 2, 0, tzinfo=UTC), + entity="user", + type="CONVERSACION", + text="Third message", + ), + ConversationEntry( + timestamp=datetime(2024, 1, 1, 10, 0, 0, tzinfo=UTC), + entity="user", + type="CONVERSACION", + text="First message", + ), + ConversationEntry( + timestamp=datetime(2024, 1, 1, 10, 1, 0, tzinfo=UTC), + entity="assistant", + type="CONVERSACION", + text="Second message", + ), + ] + + # Save messages in random order + for msg in messages_to_save: + await clean_redis.save_message(session_id, msg) + + # Retrieve and verify order + retrieved_messages = await clean_redis.get_messages(session_id) + assert len(retrieved_messages) == 3 + assert retrieved_messages[0]["text"] == "First message" + assert retrieved_messages[1]["text"] == "Second message" + assert retrieved_messages[2]["text"] == "Third message" + + async def test_get_messages_json_decode_error(self, clean_redis: RedisService) -> None: + """Test get_messages handles JSON decode errors gracefully.""" + assert clean_redis.redis is not None + session_id = "test-session-9" + key = clean_redis._messages_key(session_id) + + # Insert invalid JSON into sorted set + await clean_redis.redis.zadd(key, {"invalid json": 1000}) + await clean_redis.redis.zadd( + key, {'{"entity": "user", "text": "valid"}': 2000}, + ) + + # Should skip invalid JSON and return valid messages + messages = await clean_redis.get_messages(session_id) + # Only the valid message should be returned + assert len(messages) == 1 + assert messages[0]["entity"] == "user" + + +class TestNotificationManagement: + """Tests for notification management in Redis.""" + + async def test_save_new_notification(self, clean_redis: RedisService) -> None: + """Test saving a new notification creates new session.""" + notification = Notification.create( + id_notificacion="notif-1", + telefono="+8888888888", + texto="Test notification", + ) + + # Save notification + await clean_redis.save_or_append_notification(notification) + + # Retrieve notification session + session = await clean_redis.get_notification_session("+8888888888") + assert session is not None + + # Use inline-snapshot to verify structure + assert session.session_id == snapshot("+8888888888") + assert session.telefono == snapshot("+8888888888") + assert len(session.notificaciones) == snapshot(1) + assert session.notificaciones[0].texto == snapshot("Test notification") + assert session.notificaciones[0].id_notificacion == snapshot("notif-1") + + async def test_append_to_existing_notification_session( + self, clean_redis: RedisService, + ) -> None: + """Test appending notification to existing session.""" + phone = "+9999999999" + + # Create first notification + notification1 = Notification.create( + id_notificacion="notif-2", + telefono=phone, + texto="First notification", + ) + await clean_redis.save_or_append_notification(notification1) + + # Append second notification + notification2 = Notification.create( + id_notificacion="notif-3", + telefono=phone, + texto="Second notification", + ) + await clean_redis.save_or_append_notification(notification2) + + # Verify both notifications exist + session = await clean_redis.get_notification_session(phone) + assert session is not None + assert len(session.notificaciones) == 2 + assert session.notificaciones[0].texto == "First notification" + assert session.notificaciones[1].texto == "Second notification" + + async def test_save_notification_without_phone_raises_error( + self, clean_redis: RedisService, + ) -> None: + """Test saving notification without phone number raises ValueError.""" + notification = Notification.create( + id_notificacion="notif-4", + telefono="", + texto="Test", + ) + + with pytest.raises(ValueError, match="Phone number is required"): + await clean_redis.save_or_append_notification(notification) + + async def test_save_notification_with_whitespace_phone_raises_error( + self, clean_redis: RedisService, + ) -> None: + """Test saving notification with whitespace-only phone raises ValueError.""" + notification = Notification.create( + id_notificacion="notif-5", + telefono=" ", + texto="Test", + ) + + with pytest.raises(ValueError, match="Phone number is required"): + await clean_redis.save_or_append_notification(notification) + + async def test_get_notification_session_not_found( + self, clean_redis: RedisService, + ) -> None: + """Test retrieving non-existent notification session returns None.""" + session = await clean_redis.get_notification_session("+0000000000") + assert session is None + + async def test_get_notification_id_for_phone( + self, clean_redis: RedisService, + ) -> None: + """Test getting notification session ID for phone number.""" + phone = "+1010101010" + + # Create notification + notification = Notification.create( + id_notificacion="notif-6", + telefono=phone, + texto="Test", + ) + await clean_redis.save_or_append_notification(notification) + + # Get session ID for phone + session_id = await clean_redis.get_notification_id_for_phone(phone) + assert session_id == phone # Phone number is used as session ID + + async def test_get_notification_id_for_phone_not_found( + self, clean_redis: RedisService, + ) -> None: + """Test getting notification ID for non-existent phone returns None.""" + session_id = await clean_redis.get_notification_id_for_phone("+0000000000") + assert session_id is None + + async def test_delete_notification_session(self, clean_redis: RedisService) -> None: + """Test deleting notification session.""" + phone = "+1212121212" + + # Create notification + notification = Notification.create( + id_notificacion="notif-7", + telefono=phone, + texto="Test", + ) + await clean_redis.save_or_append_notification(notification) + + # Verify it exists + session = await clean_redis.get_notification_session(phone) + assert session is not None + + # Delete notification session + success = await clean_redis.delete_notification_session(phone) + assert success is True + + # Verify deletion + session = await clean_redis.get_notification_session(phone) + assert session is None + + async def test_delete_nonexistent_notification_session( + self, clean_redis: RedisService, + ) -> None: + """Test deleting non-existent notification session succeeds.""" + # Should not raise error + success = await clean_redis.delete_notification_session("+0000000000") + assert success is True + + async def test_phone_to_notification_mapping( + self, clean_redis: RedisService, + ) -> None: + """Test that phone-to-notification mapping is created.""" + phone = "+1313131313" + notification = Notification.create( + id_notificacion="notif-8", + telefono=phone, + texto="Test", + ) + + # Save notification + await clean_redis.save_or_append_notification(notification) + + # Verify phone mapping key exists in Redis + assert clean_redis.redis is not None + phone_key = clean_redis._phone_to_notification_key(phone) + mapped_session_id = await clean_redis.redis.get(phone_key) + assert mapped_session_id == phone + + async def test_notification_timestamps_updated( + self, clean_redis: RedisService, + ) -> None: + """Test that notification session timestamps are updated correctly.""" + phone = "+1414141414" + + # Create first notification + notification1 = Notification.create( + id_notificacion="notif-9", + telefono=phone, + texto="First", + ) + await clean_redis.save_or_append_notification(notification1) + + # Get initial session + session1 = await clean_redis.get_notification_session(phone) + assert session1 is not None + initial_update_time = session1.ultima_actualizacion + + # Wait a moment and add another notification + import asyncio + await asyncio.sleep(0.01) + + notification2 = Notification.create( + id_notificacion="notif-10", + telefono=phone, + texto="Second", + ) + await clean_redis.save_or_append_notification(notification2) + + # Get updated session + session2 = await clean_redis.get_notification_session(phone) + assert session2 is not None + + # Creation time should stay the same + assert session2.fecha_creacion == session1.fecha_creacion + + # Update time should be newer + assert session2.ultima_actualizacion > initial_update_time + + async def test_get_notification_session_deserialization_error( + self, clean_redis: RedisService, + ) -> None: + """Test get_notification_session handles deserialization errors gracefully.""" + # Manually insert invalid JSON + assert clean_redis.redis is not None + key = clean_redis._notification_key("invalid-notif-session") + await clean_redis.redis.set(key, "invalid json data") + + # Should return None on deserialization error + session = await clean_redis.get_notification_session("invalid-notif-session") + assert session is None + + +class TestErrorHandling: + """Tests for error handling in Redis operations.""" + + async def test_get_session_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test get_session raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.get_session("test-session") + + async def test_save_session_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test save_session raises error when Redis not connected.""" + service = RedisService(emulator_settings) + session = ConversationSession.create( + session_id="test", + user_id="user", + telefono="+1234567890", + ) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.save_session(session) + + async def test_delete_session_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test delete_session raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.delete_session("test-session") + + async def test_exists_when_not_connected(self, emulator_settings: Settings) -> None: + """Test exists raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.exists("test-session") + + async def test_save_message_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test save_message raises error when Redis not connected.""" + service = RedisService(emulator_settings) + message = ConversationEntry( + timestamp=datetime.now(UTC), + entity="user", + type="CONVERSACION", + text="Test", + ) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.save_message("test-session", message) + + async def test_get_messages_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test get_messages raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.get_messages("test-session") + + async def test_save_notification_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test save_or_append_notification raises error when Redis not connected.""" + service = RedisService(emulator_settings) + notification = Notification.create( + id_notificacion="notif-1", + telefono="+1234567890", + texto="Test", + ) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.save_or_append_notification(notification) + + async def test_get_notification_session_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test get_notification_session raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.get_notification_session("test-session") + + async def test_get_notification_id_for_phone_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test get_notification_id_for_phone raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.get_notification_id_for_phone("+1234567890") + + async def test_delete_notification_session_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test delete_notification_session raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service.delete_notification_session("+1234567890") + + async def test_save_session_with_redis_error( + self, clean_redis: RedisService, + ) -> None: + """Test save_session handles Redis errors gracefully.""" + session = ConversationSession.create( + session_id="test", + user_id="user", + telefono="+1234567890", + ) + + # Mock redis to raise exception + original_redis = clean_redis.redis + mock_redis = AsyncMock() + mock_redis.setex.side_effect = Exception("Redis error") + clean_redis.redis = mock_redis + + try: + result = await clean_redis.save_session(session) + assert result is False + finally: + clean_redis.redis = original_redis + + async def test_delete_session_with_redis_error( + self, clean_redis: RedisService, + ) -> None: + """Test delete_session handles Redis errors gracefully.""" + # Mock redis to raise exception + original_redis = clean_redis.redis + mock_redis = AsyncMock() + mock_redis.delete.side_effect = Exception("Redis error") + clean_redis.redis = mock_redis + + try: + result = await clean_redis.delete_session("test-session") + assert result is False + finally: + clean_redis.redis = original_redis + + async def test_save_message_with_redis_error( + self, clean_redis: RedisService, + ) -> None: + """Test save_message handles Redis errors gracefully.""" + message = ConversationEntry( + timestamp=datetime.now(UTC), + entity="user", + type="CONVERSACION", + text="Test", + ) + + # Mock redis to raise exception + original_redis = clean_redis.redis + mock_redis = AsyncMock() + mock_redis.zadd.side_effect = Exception("Redis error") + clean_redis.redis = mock_redis + + try: + result = await clean_redis.save_message("test-session", message) + assert result is False + finally: + clean_redis.redis = original_redis + + async def test_get_messages_with_redis_error( + self, clean_redis: RedisService, + ) -> None: + """Test get_messages handles Redis errors gracefully.""" + # Mock redis to raise exception + original_redis = clean_redis.redis + mock_redis = AsyncMock() + mock_redis.zrange.side_effect = Exception("Redis error") + clean_redis.redis = mock_redis + + try: + result = await clean_redis.get_messages("test-session") + assert result == [] + finally: + clean_redis.redis = original_redis + + async def test_delete_notification_session_with_redis_error( + self, clean_redis: RedisService, + ) -> None: + """Test delete_notification_session handles Redis errors gracefully.""" + # Mock redis to raise exception + original_redis = clean_redis.redis + mock_redis = AsyncMock() + mock_redis.delete.side_effect = Exception("Redis error") + clean_redis.redis = mock_redis + + try: + result = await clean_redis.delete_notification_session("+1234567890") + assert result is False + finally: + clean_redis.redis = original_redis + + async def test_cache_notification_session_when_not_connected( + self, emulator_settings: Settings, + ) -> None: + """Test _cache_notification_session raises error when Redis not connected.""" + service = RedisService(emulator_settings) + + notification_session = NotificationSession( + sessionId="test", + telefono="+1234567890", + notificaciones=[], + ) + + with pytest.raises(RuntimeError, match="Redis client not connected"): + await service._cache_notification_session(notification_session) + + async def test_cache_notification_session_with_redis_error( + self, clean_redis: RedisService, + ) -> None: + """Test _cache_notification_session handles Redis errors gracefully.""" + notification_session = NotificationSession( + sessionId="test", + telefono="+1234567890", + notificaciones=[], + ) + + # Mock redis to raise exception on setex + original_redis = clean_redis.redis + mock_redis = AsyncMock() + mock_redis.setex.side_effect = Exception("Redis error") + clean_redis.redis = mock_redis + + try: + result = await clean_redis._cache_notification_session(notification_session) + assert result is False + finally: + clean_redis.redis = original_redis + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + async def test_concurrent_session_operations( + self, clean_redis: RedisService, + ) -> None: + """Test concurrent operations on same session.""" + import asyncio + + session = ConversationSession.create( + session_id="concurrent-test", + user_id="user-999", + telefono="+1515151515", + ) + + # Save session concurrently + tasks = [clean_redis.save_session(session) for _ in range(5)] + results = await asyncio.gather(*tasks) + assert all(results) + + # Verify session exists + retrieved = await clean_redis.get_session("concurrent-test") + assert retrieved is not None + + async def test_special_characters_in_session_data( + self, clean_redis: RedisService, + ) -> None: + """Test handling special characters in session data.""" + session = ConversationSession.create( + session_id="special-chars-test", + user_id="user-special", + telefono="+1616161616", + pantalla_contexto="screen/with/slashes", + last_message='Message with emoji 🎉 and special chars: <>&"\'', + ) + + # Save and retrieve + await clean_redis.save_session(session) + retrieved = await clean_redis.get_session("special-chars-test") + + assert retrieved is not None + assert retrieved.pantalla_contexto == "screen/with/slashes" + assert retrieved.last_message is not None + assert "🎉" in retrieved.last_message + assert '<>&"' in retrieved.last_message + + async def test_unicode_in_notification_text( + self, clean_redis: RedisService, + ) -> None: + """Test handling unicode characters in notification text.""" + notification = Notification.create( + id_notificacion="unicode-test", + telefono="+1717171717", + texto="Notification with unicode: 你好世界 مرحبا العالم 🌍", + ) + + # Save and retrieve + await clean_redis.save_or_append_notification(notification) + session = await clean_redis.get_notification_session("+1717171717") + + assert session is not None + assert "你好世界" in session.notificaciones[0].texto + assert "مرحبا العالم" in session.notificaciones[0].texto + assert "🌍" in session.notificaciones[0].texto + + async def test_large_message_text(self, clean_redis: RedisService) -> None: + """Test handling large message text.""" + large_text = "A" * 10000 # 10KB of text + message = ConversationEntry( + timestamp=datetime.now(UTC), + entity="user", + type="CONVERSACION", + text=large_text, + ) + + session_id = "large-message-test" + success = await clean_redis.save_message(session_id, message) + assert success is True + + # Retrieve and verify + messages = await clean_redis.get_messages(session_id) + assert len(messages) == 1 + assert len(messages[0]["text"]) == 10000 + + async def test_many_messages_in_session(self, clean_redis: RedisService) -> None: + """Test handling many messages in a single session.""" + session_id = "many-messages-test" + + # Save 100 messages + for i in range(100): + message = ConversationEntry( + timestamp=datetime.now(UTC), + entity="user" if i % 2 == 0 else "assistant", + type="CONVERSACION", + text=f"Message {i}", + ) + await clean_redis.save_message(session_id, message) + + # Retrieve all messages + messages = await clean_redis.get_messages(session_id) + assert len(messages) == 100 + + async def test_many_notifications_in_session( + self, clean_redis: RedisService, + ) -> None: + """Test handling many notifications in a single session.""" + phone = "+1818181818" + + # Add 50 notifications + for i in range(50): + notification = Notification.create( + id_notificacion=f"notif-{i}", + telefono=phone, + texto=f"Notification {i}", + ) + await clean_redis.save_or_append_notification(notification) + + # Retrieve session + session = await clean_redis.get_notification_session(phone) + assert session is not None + assert len(session.notificaciones) == 50 + + async def test_session_ttl_is_set(self, clean_redis: RedisService) -> None: + """Test that session TTL is set in Redis.""" + session = ConversationSession.create( + session_id="ttl-test", + user_id="user-ttl", + telefono="+1919191919", + ) + + # Save session + await clean_redis.save_session(session) + + # Check TTL is set + assert clean_redis.redis is not None + key = clean_redis._session_key("ttl-test") + ttl = await clean_redis.redis.ttl(key) + assert ttl > 0 + assert ttl <= clean_redis.session_ttl + + async def test_notification_ttl_is_set(self, clean_redis: RedisService) -> None: + """Test that notification TTL is set in Redis.""" + notification = Notification.create( + id_notificacion="ttl-notif", + telefono="+2020202020", + texto="Test", + ) + + # Save notification + await clean_redis.save_or_append_notification(notification) + + # Check TTL is set + assert clean_redis.redis is not None + key = clean_redis._notification_key("+2020202020") + ttl = await clean_redis.redis.ttl(key) + assert ttl > 0 + assert ttl <= clean_redis.notification_ttl + + async def test_message_ttl_is_set(self, clean_redis: RedisService) -> None: + """Test that message TTL is set in Redis.""" + session_id = "message-ttl-test" + message = ConversationEntry( + timestamp=datetime.now(UTC), + entity="user", + type="CONVERSACION", + text="Test", + ) + + # Save message + await clean_redis.save_message(session_id, message) + + # Check TTL is set + assert clean_redis.redis is not None + key = clean_redis._messages_key(session_id) + ttl = await clean_redis.redis.ttl(key) + assert ttl > 0 + assert ttl <= clean_redis.session_ttl diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..b1ea62f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,18 @@ +"""Tests for configuration settings.""" + +from pathlib import Path + +from capa_de_integracion.config import Settings + + +def test_settings_base_path(): + """Test settings base_path property.""" + settings = Settings.model_validate({}) + base_path = settings.base_path + + assert isinstance(base_path, Path) + # Check that the path ends with /resources relative to the package + assert base_path.name == "resources" + # Verify the path contains the project directory + assert "resources" in str(base_path) + assert str(base_path).endswith("resources") diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py new file mode 100644 index 0000000..beeac82 --- /dev/null +++ b/tests/test_dependencies.py @@ -0,0 +1,205 @@ +"""Tests for dependency injection.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from capa_de_integracion.config import Settings +from capa_de_integracion.dependencies import ( + get_conversation_manager, + get_dlp_service, + get_firestore_service, + get_notification_manager, + get_quick_reply_content_service, + get_rag_service, + get_redis_service, + init_services, + shutdown_services, + startup_services, +) +from capa_de_integracion.services import ( + ConversationManagerService, + DLPService, + NotificationManagerService, + QuickReplyContentService, +) +from capa_de_integracion.services.firestore_service import FirestoreService +from capa_de_integracion.services.rag import EchoRAGService, HTTPRAGService +from capa_de_integracion.services.redis_service import RedisService + + +def test_get_redis_service(): + """Test get_redis_service returns RedisService.""" + # Clear cache first + get_redis_service.cache_clear() + + service = get_redis_service() + assert isinstance(service, RedisService) + + # Should return same instance (cached) + service2 = get_redis_service() + assert service is service2 + + +def test_get_firestore_service(): + """Test get_firestore_service returns FirestoreService.""" + get_firestore_service.cache_clear() + + service = get_firestore_service() + assert isinstance(service, FirestoreService) + + # Should return same instance (cached) + service2 = get_firestore_service() + assert service is service2 + + +def test_get_dlp_service(): + """Test get_dlp_service returns DLPService.""" + get_dlp_service.cache_clear() + + service = get_dlp_service() + assert isinstance(service, DLPService) + + # Should return same instance (cached) + service2 = get_dlp_service() + assert service is service2 + + +def test_get_quick_reply_content_service(): + """Test get_quick_reply_content_service returns QuickReplyContentService.""" + get_quick_reply_content_service.cache_clear() + + service = get_quick_reply_content_service() + assert isinstance(service, QuickReplyContentService) + + # Should return same instance (cached) + service2 = get_quick_reply_content_service() + assert service is service2 + + +def test_get_notification_manager(): + """Test get_notification_manager returns NotificationManagerService.""" + get_notification_manager.cache_clear() + get_redis_service.cache_clear() + get_firestore_service.cache_clear() + get_dlp_service.cache_clear() + + service = get_notification_manager() + assert isinstance(service, NotificationManagerService) + + # Should return same instance (cached) + service2 = get_notification_manager() + assert service is service2 + + +def test_get_rag_service_http(): + """Test get_rag_service returns HTTPRAGService when echo disabled.""" + get_rag_service.cache_clear() + + with patch("capa_de_integracion.dependencies.settings") as mock_settings, \ + patch("capa_de_integracion.dependencies.HTTPRAGService") as mock_http_rag: + mock_settings.rag_echo_enabled = False + mock_settings.rag_endpoint_url = "http://test.example.com" + + mock_http_rag.return_value = Mock(spec=HTTPRAGService) + + service = get_rag_service() + + mock_http_rag.assert_called_once() + assert service is not None + + +def test_get_rag_service_echo(): + """Test get_rag_service returns EchoRAGService when echo enabled.""" + get_rag_service.cache_clear() + + with patch("capa_de_integracion.dependencies.settings") as mock_settings: + mock_settings.rag_echo_enabled = True + + service = get_rag_service() + assert isinstance(service, EchoRAGService) + + +def test_get_conversation_manager(): + """Test get_conversation_manager returns ConversationManagerService.""" + get_conversation_manager.cache_clear() + get_redis_service.cache_clear() + get_firestore_service.cache_clear() + get_dlp_service.cache_clear() + get_rag_service.cache_clear() + + with patch("capa_de_integracion.dependencies.get_rag_service") as mock_get_rag: + mock_get_rag.return_value = Mock(spec=EchoRAGService) + + service = get_conversation_manager() + assert isinstance(service, ConversationManagerService) + + # Should return same instance (cached) + service2 = get_conversation_manager() + assert service is service2 + + +def test_init_services(): + """Test init_services (placeholder function).""" + settings = Settings.model_validate({}) + # Should not raise - it's a placeholder + init_services(settings) + + +@pytest.mark.asyncio +async def test_startup_services(emulator_settings): + """Test startup_services connects to Redis.""" + get_redis_service.cache_clear() + + # Mock Redis service to avoid actual connection + with patch("capa_de_integracion.dependencies.get_redis_service") as mock_get_redis: + from unittest.mock import AsyncMock + + mock_redis = Mock(spec=RedisService) + mock_redis.connect = AsyncMock() + mock_get_redis.return_value = mock_redis + + await startup_services() + + mock_redis.connect.assert_called_once() + + +@pytest.mark.asyncio +async def test_shutdown_services(emulator_settings): + """Test shutdown_services closes all services.""" + get_redis_service.cache_clear() + get_firestore_service.cache_clear() + get_dlp_service.cache_clear() + get_rag_service.cache_clear() + + # Create mock services + with patch("capa_de_integracion.dependencies.get_redis_service") as mock_get_redis, \ + patch("capa_de_integracion.dependencies.get_firestore_service") as mock_get_firestore, \ + patch("capa_de_integracion.dependencies.get_dlp_service") as mock_get_dlp, \ + patch("capa_de_integracion.dependencies.get_rag_service") as mock_get_rag: + + from unittest.mock import AsyncMock + + mock_redis = Mock(spec=RedisService) + mock_redis.close = AsyncMock() + mock_get_redis.return_value = mock_redis + + mock_firestore = Mock(spec=FirestoreService) + mock_firestore.close = AsyncMock() + mock_get_firestore.return_value = mock_firestore + + mock_dlp = Mock(spec=DLPService) + mock_dlp.close = AsyncMock() + mock_get_dlp.return_value = mock_dlp + + mock_rag = Mock(spec=EchoRAGService) + mock_rag.close = AsyncMock() + mock_get_rag.return_value = mock_rag + + await shutdown_services() + + # Verify each service's close method was called + mock_redis.close.assert_called_once() + mock_firestore.close.assert_called_once() + mock_dlp.close.assert_called_once() + mock_rag.close.assert_called_once() diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..2284688 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,37 @@ +"""Tests for custom exceptions.""" + +import pytest + +from capa_de_integracion.exceptions import FirestorePersistenceError + + +def test_firestore_persistence_error_basic(): + """Test FirestorePersistenceError with message only.""" + error = FirestorePersistenceError("Test error message") + assert str(error) == "Test error message" + assert error.cause is None + + +def test_firestore_persistence_error_with_cause(): + """Test FirestorePersistenceError with cause exception.""" + cause = ValueError("Original error") + error = FirestorePersistenceError("Wrapped error", cause=cause) + + assert str(error) == "Wrapped error" + assert error.cause is cause + assert isinstance(error.cause, ValueError) + assert str(error.cause) == "Original error" + + +def test_firestore_persistence_error_inheritance(): + """Test that FirestorePersistenceError is an Exception.""" + error = FirestorePersistenceError("Test") + assert isinstance(error, Exception) + + +def test_firestore_persistence_error_can_be_raised(): + """Test that the exception can be raised and caught.""" + with pytest.raises(FirestorePersistenceError) as exc_info: + raise FirestorePersistenceError("Test error") + + assert str(exc_info.value) == "Test error" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..47b48ff --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,93 @@ +"""Tests for main application module.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from fastapi.testclient import TestClient + +from capa_de_integracion.main import app, health_check, main + + +def test_health_check(): + """Test health check endpoint returns healthy status.""" + client = TestClient(app) + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["service"] == "capa-de-integracion" + + +@pytest.mark.asyncio +async def test_health_check_direct(): + """Test health check function directly.""" + result = await health_check() + + assert result["status"] == "healthy" + assert result["service"] == "capa-de-integracion" + + +def test_app_title(): + """Test app has correct title and description.""" + assert app.title == "Capa de Integración - Orchestrator Service" + assert "Conversational AI" in app.description + assert app.version == "0.1.0" + + +def test_app_has_routers(): + """Test app has all required routers registered.""" + routes = [route.path for route in app.routes] + + assert "/api/v1/dialogflow/detect-intent" in routes + assert "/api/v1/dialogflow/notification" in routes + assert "/api/v1/quick-replies/screen" in routes + assert "/health" in routes + + +def test_main_entry_point(): + """Test main entry point calls uvicorn.run.""" + with patch("capa_de_integracion.main.uvicorn.run") as mock_run: + main() + + mock_run.assert_called_once() + call_kwargs = mock_run.call_args.kwargs + assert call_kwargs["host"] == "0.0.0.0" + assert call_kwargs["port"] == 8080 + assert call_kwargs["reload"] is True + + +@pytest.mark.asyncio +async def test_lifespan_startup(): + """Test lifespan startup calls initialization functions.""" + with patch("capa_de_integracion.main.init_services") as mock_init, \ + patch("capa_de_integracion.main.startup_services") as mock_startup, \ + patch("capa_de_integracion.main.shutdown_services") as mock_shutdown: + + mock_startup.return_value = None + mock_shutdown.return_value = None + + # Simulate lifespan + from capa_de_integracion.main import lifespan + + async with lifespan(app): + mock_init.assert_called_once() + + +@pytest.mark.asyncio +async def test_lifespan_shutdown(): + """Test lifespan shutdown calls shutdown function.""" + with patch("capa_de_integracion.main.init_services"), \ + patch("capa_de_integracion.main.startup_services") as mock_startup, \ + patch("capa_de_integracion.main.shutdown_services") as mock_shutdown: + + mock_startup.return_value = None + mock_shutdown.return_value = None + + from capa_de_integracion.main import lifespan + + async with lifespan(app): + pass + + # After context exits, shutdown should be called + # Can't easily assert this in the current structure, but the test exercises the code diff --git a/tests/test_routers_simple.py b/tests/test_routers_simple.py new file mode 100644 index 0000000..44e16b6 --- /dev/null +++ b/tests/test_routers_simple.py @@ -0,0 +1,138 @@ +"""Simplified router tests using direct function calls.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from capa_de_integracion.models import ConversationRequest, DetectIntentResponse, User +from capa_de_integracion.models.notification import ExternalNotificationRequest +from capa_de_integracion.models.quick_replies import QuickReplyScreen +from capa_de_integracion.routers import conversation, notification, quick_replies + + +@pytest.mark.asyncio +async def test_detect_intent_success(): + """Test detect intent endpoint with success.""" + mock_manager = Mock() + mock_manager.manage_conversation = AsyncMock( + return_value=DetectIntentResponse( + response_id="test-123", + query_result=None, + ), + ) + + request = ConversationRequest( + mensaje="Hello", + usuario=User(telefono="555-1234"), + canal="web", + ) + + response = await conversation.detect_intent(request, mock_manager) + + assert response.response_id == "test-123" + mock_manager.manage_conversation.assert_called_once() + + +@pytest.mark.asyncio +async def test_detect_intent_value_error(): + """Test detect intent with ValueError.""" + mock_manager = Mock() + mock_manager.manage_conversation = AsyncMock( + side_effect=ValueError("Invalid input"), + ) + + request = ConversationRequest( + mensaje="Test", + usuario=User(telefono="555-1234"), + canal="web", + ) + + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + await conversation.detect_intent(request, mock_manager) + + assert exc_info.value.status_code == 400 + assert "Invalid input" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_detect_intent_general_error(): + """Test detect intent with general Exception.""" + mock_manager = Mock() + mock_manager.manage_conversation = AsyncMock( + side_effect=RuntimeError("Server error"), + ) + + request = ConversationRequest( + mensaje="Test", + usuario=User(telefono="555-1234"), + canal="web", + ) + + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + await conversation.detect_intent(request, mock_manager) + + assert exc_info.value.status_code == 500 + assert "Internal server error" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_process_notification_success(): + """Test notification processing success.""" + mock_manager = Mock() + mock_manager.process_notification = AsyncMock() + + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Your card was blocked", + ) + + result = await notification.process_notification(request, mock_manager) + + assert result is None + mock_manager.process_notification.assert_called_once() + + +@pytest.mark.asyncio +async def test_process_notification_value_error(): + """Test notification with ValueError.""" + mock_manager = Mock() + mock_manager.process_notification = AsyncMock( + side_effect=ValueError("Invalid phone"), + ) + + request = ExternalNotificationRequest( + telefono="", + texto="Test", + ) + + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + await notification.process_notification(request, mock_manager) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_process_notification_general_error(): + """Test notification with general error.""" + mock_manager = Mock() + mock_manager.process_notification = AsyncMock( + side_effect=RuntimeError("Server error"), + ) + + request = ExternalNotificationRequest( + telefono="555-1234", + texto="Test", + ) + + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + await notification.process_notification(request, mock_manager) + + assert exc_info.value.status_code == 500 diff --git a/uv.lock b/uv.lock index 861d58d..0e1749c 100644 --- a/uv.lock +++ b/uv.lock @@ -7,12 +7,6 @@ resolution-markers = [ "python_full_version < '3.13'", ] -[manifest] -members = [ - "capa-de-integracion", - "rag-client", -] - [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -180,10 +174,10 @@ dependencies = [ { name = "google-cloud-dlp" }, { name = "google-cloud-firestore" }, { name = "google-generativeai" }, + { name = "httpx" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-multipart" }, - { name = "rag-client" }, { name = "redis", extra = ["hiredis"] }, { name = "tenacity" }, { name = "uvicorn", extra = ["standard"] }, @@ -191,6 +185,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "fakeredis" }, { name = "inline-snapshot" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -209,10 +204,10 @@ requires-dist = [ { name = "google-cloud-dlp", specifier = ">=3.30.0" }, { name = "google-cloud-firestore", specifier = ">=2.20.0" }, { name = "google-generativeai", specifier = ">=0.8.0" }, + { name = "httpx", specifier = ">=0.27.0" }, { name = "pydantic", specifier = ">=2.10.0" }, { name = "pydantic-settings", specifier = ">=2.6.0" }, { name = "python-multipart", specifier = ">=0.0.12" }, - { name = "rag-client", editable = "packages/rag-client" }, { name = "redis", extras = ["hiredis"], specifier = ">=5.2.0" }, { name = "tenacity", specifier = ">=9.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32.0" }, @@ -220,6 +215,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "fakeredis", specifier = ">=2.34.0" }, { name = "inline-snapshot", specifier = ">=0.32.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, @@ -538,6 +534,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "fakeredis" +version = "2.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "redis" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/44/c403963727d707e03f49a417712b0a23e853d33ae50729679040b6cfe281/fakeredis-2.34.0.tar.gz", hash = "sha256:72bc51a7ab39bedf5004f0cf1b5206822619c1be8c2657fd878d1f4250256c57", size = 177156, upload-time = "2026-02-16T15:56:34.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/8e/af19c00753c432355f9b76cec3ab0842578de43ba575e82735b18c1b3ec9/fakeredis-2.34.0-py3-none-any.whl", hash = "sha256:bc45d362c6cc3a537f8287372d8ea532538dfbe7f5d635d0905d7b3464ec51d2", size = 122063, upload-time = "2026-02-16T15:56:21.227Z" }, +] + [[package]] name = "fastapi" version = "0.129.0" @@ -1738,21 +1747,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "rag-client" -version = "0.1.0" -source = { editable = "packages/rag-client" } -dependencies = [ - { name = "httpx" }, - { name = "pydantic" }, -] - -[package.metadata] -requires-dist = [ - { name = "httpx", specifier = ">=0.27.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, -] - [[package]] name = "redis" version = "7.2.0" @@ -1850,6 +1844,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "starlette" version = "0.52.1"