Improve coverage
This commit is contained in:
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
231
scripts/load_test.py
Executable file
231
scripts/load_test.py
Executable file
@@ -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())
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
result = await quick_reply_session_service.start_quick_reply_session(
|
||||
telefono=request.usuario.telefono,
|
||||
_nombre=request.usuario.nombre,
|
||||
pantalla_contexto=request.pantalla_contexto,
|
||||
)
|
||||
|
||||
# Cache session
|
||||
await redis_service.save_session(session)
|
||||
logger.info(
|
||||
"Created quick reply session %s for screen: %s",
|
||||
session_id,
|
||||
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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
121
src/capa_de_integracion/services/quick_reply_session_service.py
Normal file
121
src/capa_de_integracion/services/quick_reply_session_service.py
Normal file
@@ -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,
|
||||
)
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
216
tests/services/test_dlp_service.py
Normal file
216
tests/services/test_dlp_service.py
Normal file
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
159
tests/services/test_notification_manager.py
Normal file
159
tests/services/test_notification_manager.py
Normal file
@@ -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
|
||||
170
tests/services/test_quick_reply_content.py
Normal file
170
tests/services/test_quick_reply_content.py
Normal file
@@ -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")
|
||||
251
tests/services/test_rag_services.py
Normal file
251
tests/services/test_rag_services.py
Normal file
@@ -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
|
||||
928
tests/services/test_redis_service.py
Normal file
928
tests/services/test_redis_service.py
Normal file
@@ -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
|
||||
18
tests/test_config.py
Normal file
18
tests/test_config.py
Normal file
@@ -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")
|
||||
205
tests/test_dependencies.py
Normal file
205
tests/test_dependencies.py
Normal file
@@ -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()
|
||||
37
tests/test_exceptions.py
Normal file
37
tests/test_exceptions.py
Normal file
@@ -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"
|
||||
93
tests/test_main.py
Normal file
93
tests/test_main.py
Normal file
@@ -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
|
||||
138
tests/test_routers_simple.py
Normal file
138
tests/test_routers_simple.py
Normal file
@@ -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
|
||||
49
uv.lock
generated
49
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user