Compare commits
6 Commits
eb0f03ab7e
...
891df5e82b
| Author | SHA1 | Date | |
|---|---|---|---|
| 891df5e82b | |||
| 761253a056 | |||
| d328d801f4 | |||
| 34053b3fc1 | |||
| 7fa4d12e30 | |||
| 8dffd91bd7 |
@@ -5,7 +5,9 @@ from naliiabot.bot.factories.llm_factory import LLMFactory
|
||||
from naliiabot.bot.prompts.load_prompt import get_prompt_template
|
||||
from naliiabot.bot.tools.naliia_tools import NaliiaTools
|
||||
from naliiabotapi.api.v1.endpoints.chat import router as chat_router
|
||||
from naliiabotapi.api.v1.webhooks.chat_hook import router as chat_hook_router
|
||||
from naliiabotapi.api.v1.webhooks.chat_hook import (
|
||||
router as chat_hook_router,
|
||||
)
|
||||
import naliiabotapi.api.dependencies as deps
|
||||
from contextlib import asynccontextmanager
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
@@ -30,7 +32,7 @@ async def lifespan(app: FastAPI):
|
||||
model=model,
|
||||
config=config,
|
||||
checkpointer=checkpointer,
|
||||
tools=naliia_tools
|
||||
tools=naliia_tools,
|
||||
)
|
||||
|
||||
yield
|
||||
@@ -42,10 +44,11 @@ app = FastAPI(
|
||||
title="NaliiaBot API",
|
||||
description=(
|
||||
"API for NaliiaBot, a chatbot that provides "
|
||||
"customer service and related topics."),
|
||||
"customer service and related topics."
|
||||
),
|
||||
version="1.0.0",
|
||||
root_path="/agent/api",
|
||||
lifespan=lifespan
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.include_router(chat_router)
|
||||
|
||||
@@ -1,19 +1,93 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, MagicMock
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from src.naliiabot.bot.agent.agent import Agent, AgentConfig
|
||||
from src.naliiabot.bot.factories.llm_factory import LLMFactory
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from naliiabotapi.main import app
|
||||
from fastapi import FastAPI
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
from naliiabot.bot.prompts.load_prompt import get_prompt_template
|
||||
from naliiabot.bot.tools.naliia_tools import NaliiaTools
|
||||
import naliiabotapi.api.dependencies as deps
|
||||
from naliiabotapi.api.v1.endpoints.chat import router as chat_router
|
||||
from naliiabotapi.api.v1.webhooks.chat_hook import (
|
||||
router as chat_hook_router,
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def test_lifespan(app: FastAPI):
|
||||
"""Test lifespan that uses in-memory checkpointer instead of PostgreSQL."""
|
||||
# Use in-memory checkpointer for tests
|
||||
checkpointer = MemorySaver()
|
||||
|
||||
# Use a mock model for tests to avoid needing API keys
|
||||
# The mock needs to return proper AIMessage objects
|
||||
mock_model = MagicMock()
|
||||
|
||||
def mock_invoke(*args, **kwargs):
|
||||
"""Mock invoke that returns a proper AIMessage."""
|
||||
return AIMessage(
|
||||
content="This is a test response from the mock model."
|
||||
)
|
||||
|
||||
mock_model.invoke = mock_invoke
|
||||
mock_model.bind_tools = Mock(return_value=mock_model)
|
||||
|
||||
naliia_prompt = get_prompt_template("NALIIA_PROMPT")
|
||||
naliia_tools = NaliiaTools().get_tools()
|
||||
config = AgentConfig(system_prompt=naliia_prompt)
|
||||
|
||||
deps.agent_instance = Agent(
|
||||
model=mock_model,
|
||||
config=config,
|
||||
checkpointer=checkpointer,
|
||||
tools=naliia_tools,
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
deps.agent_instance = None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_app():
|
||||
"""Create a test FastAPI app with in-memory dependencies."""
|
||||
app = FastAPI(
|
||||
title="NaliiaBot API - Test",
|
||||
description="Test API for NaliiaBot",
|
||||
version="1.0.0",
|
||||
root_path="/agent/api",
|
||||
lifespan=test_lifespan,
|
||||
)
|
||||
|
||||
app.include_router(chat_router)
|
||||
app.include_router(chat_hook_router)
|
||||
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"Hello": "World"}
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
async def async_client():
|
||||
async def async_client(test_app):
|
||||
"""Fixture para un cliente HTTP asíncrono."""
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://localhost:8010"
|
||||
transport=ASGITransport(app=test_app),
|
||||
base_url="http://localhost:8010",
|
||||
) as client:
|
||||
|
||||
yield client
|
||||
@@ -40,9 +114,7 @@ def mock_model():
|
||||
def default_config():
|
||||
"""Configuración de test predecible."""
|
||||
return AgentConfig(
|
||||
system_prompt="Test prompt",
|
||||
max_iterations=5,
|
||||
timeout_seconds=10.0
|
||||
system_prompt="Test prompt", max_iterations=5, timeout_seconds=10.0
|
||||
)
|
||||
|
||||
|
||||
@@ -56,9 +128,7 @@ def agent(mock_model, default_config):
|
||||
def agent_with_tool(mock_model, default_config, mock_tool):
|
||||
"""Agente con una herramienta mock."""
|
||||
return Agent(
|
||||
model=mock_model,
|
||||
config=default_config,
|
||||
tools=[mock_tool]
|
||||
model=mock_model, config=default_config, tools=[mock_tool]
|
||||
)
|
||||
|
||||
|
||||
@@ -80,8 +150,10 @@ def send_message_payload():
|
||||
"key": {
|
||||
"remoteJid": "5512992544490@s.whatsapp.net",
|
||||
"remoteJidAlt": "5512992544490@s.whatsapp.net",
|
||||
"fromMe": False, "id": "AC4367597BDC50537133B9C8848B2E62",
|
||||
"participant": "", "addressingMode": "lid"
|
||||
"fromMe": False,
|
||||
"id": "AC4367597BDC50537133B9C8848B2E62",
|
||||
"participant": "",
|
||||
"addressingMode": "lid",
|
||||
},
|
||||
"pushName": "Alejandro",
|
||||
"status": "DELIVERY_ACK",
|
||||
@@ -102,12 +174,12 @@ def send_message_payload():
|
||||
"6": 172,
|
||||
"7": 243,
|
||||
"8": 27,
|
||||
"9": 98
|
||||
"9": 98,
|
||||
},
|
||||
"senderTimestamp": {
|
||||
"low": 1772156772,
|
||||
"high": 0,
|
||||
"unsigned": "true"
|
||||
"unsigned": "true",
|
||||
},
|
||||
"recipientKeyHash": {
|
||||
"0": 16,
|
||||
@@ -119,13 +191,13 @@ def send_message_payload():
|
||||
"6": 24,
|
||||
"7": 225,
|
||||
"8": 244,
|
||||
"9": 126
|
||||
"9": 126,
|
||||
},
|
||||
"recipientTimestamp": {
|
||||
"low": 1772155597,
|
||||
"high": 0,
|
||||
"unsigned": True
|
||||
}
|
||||
"unsigned": True,
|
||||
},
|
||||
},
|
||||
"deviceListMetadataVersion": 2,
|
||||
"messageSecret": {
|
||||
@@ -160,16 +232,34 @@ def send_message_payload():
|
||||
"28": 87,
|
||||
"29": 176,
|
||||
"30": 135,
|
||||
"31": 62
|
||||
}}},
|
||||
"31": 62,
|
||||
},
|
||||
},
|
||||
},
|
||||
"messageType": "conversation",
|
||||
"messageTimestamp": 1772168493,
|
||||
"instanceId": "75a877f1-d772-422b-a6f1-6ac470af5663",
|
||||
"source": "android"
|
||||
"source": "android",
|
||||
},
|
||||
"destination": "http://192.168.58.110:8010/webhook",
|
||||
"date_time": "2026-02-27T02:01:34.178Z",
|
||||
"sender": "573016859278@s.whatsapp.net",
|
||||
"server_url": "http://localhost:8080",
|
||||
"apikey": "98B6B820AFC3-4544-9A4C-011B0719C2D1"
|
||||
"apikey": "98B6B820AFC3-4544-9A4C-011B0719C2D1",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_anthropic():
|
||||
"""Mock para ChatAnthropic que no requiere API key."""
|
||||
mock = Mock(spec=ChatAnthropic)
|
||||
mock.model_name = "claude-sonnet-4-5-20250929"
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_chat_deepseek():
|
||||
"""Mock para ChatDeepSeek que no requiere API key."""
|
||||
mock = Mock(spec=ChatDeepSeek)
|
||||
mock.model_name = "deepseek-chat"
|
||||
return mock
|
||||
|
||||
@@ -4,7 +4,6 @@ from src.naliiabot.bot.agent.agent import Agent
|
||||
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
|
||||
from langgraph.graph import END
|
||||
|
||||
|
||||
"""
|
||||
Tests unitarios robustos para el Agente.
|
||||
|
||||
@@ -32,7 +31,7 @@ class TestAgentConstruction:
|
||||
|
||||
agent = Agent(model=mock_model)
|
||||
|
||||
assert agent.config.max_iterations == 10
|
||||
assert agent.config.max_iterations == 100
|
||||
assert "helpful assistant" in agent.config.system_prompt
|
||||
assert agent.tools == []
|
||||
|
||||
@@ -113,6 +112,7 @@ class TestGraphStructure:
|
||||
# TESTS DE COMPORTAMIENTO DE NODOS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestLLMNode:
|
||||
"""Tests del nodo de llamada al LLM."""
|
||||
|
||||
@@ -126,10 +126,7 @@ class TestLLMNode:
|
||||
mock_response = AIMessage(content="Hola")
|
||||
agent._model.invoke = Mock(return_value=mock_response)
|
||||
|
||||
state = {
|
||||
"messages": [HumanMessage(content="Hi")],
|
||||
"llm_calls": 2
|
||||
}
|
||||
state = {"messages": [HumanMessage(content="Hi")], "llm_calls": 2}
|
||||
|
||||
result = agent._llm_call_node(state)
|
||||
|
||||
@@ -166,7 +163,7 @@ class TestLLMNode:
|
||||
|
||||
state = {
|
||||
"messages": [HumanMessage(content="Hi")],
|
||||
"llm_calls": 5 # Ya en límite
|
||||
"llm_calls": 5, # Ya en límite
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="Max iterations"):
|
||||
@@ -203,11 +200,13 @@ class TestToolNode:
|
||||
|
||||
ai_msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{
|
||||
"name": "calculator",
|
||||
"args": {"x": 1, "y": 2},
|
||||
"id": "call_123"
|
||||
}]
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "calculator",
|
||||
"args": {"x": 1, "y": 2},
|
||||
"id": "call_123",
|
||||
}
|
||||
],
|
||||
)
|
||||
state = {"messages": [HumanMessage(content="Calc"), ai_msg]}
|
||||
|
||||
@@ -227,11 +226,9 @@ class TestToolNode:
|
||||
|
||||
ai_msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{
|
||||
"name": "unknown_tool",
|
||||
"args": {},
|
||||
"id": "call_123"
|
||||
}]
|
||||
tool_calls=[
|
||||
{"name": "unknown_tool", "args": {}, "id": "call_123"}
|
||||
],
|
||||
)
|
||||
state = {"messages": [ai_msg]}
|
||||
|
||||
@@ -254,7 +251,9 @@ class TestToolNode:
|
||||
|
||||
ai_msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "calculator", "args": {}, "id": "call_123"}]
|
||||
tool_calls=[
|
||||
{"name": "calculator", "args": {}, "id": "call_123"}
|
||||
],
|
||||
)
|
||||
state = {"messages": [ai_msg]}
|
||||
|
||||
@@ -276,11 +275,9 @@ class TestToolNode:
|
||||
|
||||
ai_msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{
|
||||
"name": "calculator",
|
||||
"args": {},
|
||||
"id": "call_123"
|
||||
}]
|
||||
tool_calls=[
|
||||
{"name": "calculator", "args": {}, "id": "call_123"}
|
||||
],
|
||||
)
|
||||
state = {"messages": [ai_msg]}
|
||||
|
||||
@@ -314,7 +311,7 @@ class TestShouldContinue:
|
||||
"""
|
||||
ai_msg = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{"name": "calc", "args": {}, "id": "1"}]
|
||||
tool_calls=[{"name": "calc", "args": {}, "id": "1"}],
|
||||
)
|
||||
state = {"messages": [ai_msg]}
|
||||
|
||||
@@ -354,6 +351,7 @@ class TestShouldContinue:
|
||||
# TESTS DE INTEGRACIÓN CON FLUJO COMPLETO
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestIntegrationFlows:
|
||||
"""Tests de flujos completos con mocks controlados."""
|
||||
|
||||
@@ -363,12 +361,12 @@ class TestIntegrationFlows:
|
||||
CUANDO: Usuario pregunta algo simple
|
||||
ENTONCES: Flujo: START → llm_call → END
|
||||
"""
|
||||
final_response = AIMessage(content="¡Hola! ¿En qué puedo ayudarte?")
|
||||
final_response = AIMessage(
|
||||
content="¡Hola! ¿En qué puedo ayudarte?"
|
||||
)
|
||||
agent._model.invoke = Mock(return_value=final_response)
|
||||
|
||||
initial_state = {
|
||||
"messages": [HumanMessage(content="Hola")]
|
||||
}
|
||||
initial_state = {"messages": [HumanMessage(content="Hola")]}
|
||||
|
||||
result = agent.invoke(initial_state)
|
||||
|
||||
@@ -377,7 +375,8 @@ class TestIntegrationFlows:
|
||||
assert result["llm_calls"] == 1
|
||||
assert len(result["messages"]) == 2
|
||||
assert result["messages"][-1].content == (
|
||||
"¡Hola! ¿En qué puedo ayudarte?")
|
||||
"¡Hola! ¿En qué puedo ayudarte?"
|
||||
)
|
||||
|
||||
def test_tool_use_flow(self, agent_with_tool):
|
||||
"""
|
||||
@@ -388,23 +387,22 @@ class TestIntegrationFlows:
|
||||
# Primera llamada: LLM decide usar tool
|
||||
first_response = AIMessage(
|
||||
content="",
|
||||
tool_calls=[{
|
||||
"name": "calculator",
|
||||
"args": {"expr": "2+2"},
|
||||
"id": "calc_1"
|
||||
}]
|
||||
tool_calls=[
|
||||
{
|
||||
"name": "calculator",
|
||||
"args": {"expr": "2+2"},
|
||||
"id": "calc_1",
|
||||
}
|
||||
],
|
||||
)
|
||||
# Segunda llamada: LLM responde con resultado
|
||||
second_response = AIMessage(content="El resultado es 42")
|
||||
|
||||
agent_with_tool._model.invoke = Mock(side_effect=[
|
||||
first_response,
|
||||
second_response
|
||||
])
|
||||
agent_with_tool._model.invoke = Mock(
|
||||
side_effect=[first_response, second_response]
|
||||
)
|
||||
|
||||
initial_state = {
|
||||
"messages": [HumanMessage(content="Calcula 2+2")]
|
||||
}
|
||||
initial_state = {"messages": [HumanMessage(content="Calcula 2+2")]}
|
||||
|
||||
result = agent_with_tool.invoke(initial_state)
|
||||
|
||||
@@ -431,6 +429,7 @@ class TestIntegrationFlows:
|
||||
# TESTS DE EJECUCIÓN DE TOOLS (UNITARIO DETALLADO)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestToolExecution:
|
||||
"""Tests específicos del método _execute_tool."""
|
||||
|
||||
@@ -443,7 +442,7 @@ class TestToolExecution:
|
||||
tool_call = {
|
||||
"name": "calculator",
|
||||
"args": {"x": 10},
|
||||
"id": "call_123"
|
||||
"id": "call_123",
|
||||
}
|
||||
|
||||
result = agent_with_tool._execute_tool(tool_call)
|
||||
@@ -459,11 +458,7 @@ class TestToolExecution:
|
||||
CUANDO: Ejecuto _execute_tool
|
||||
ENTONCES: Retorna ToolMessage con error
|
||||
"""
|
||||
tool_call = {
|
||||
"name": "nonexistent",
|
||||
"args": {},
|
||||
"id": "call_404"
|
||||
}
|
||||
tool_call = {"name": "nonexistent", "args": {}, "id": "call_404"}
|
||||
|
||||
result = agent._execute_tool(tool_call)
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from src.naliiabotapi.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with TestClient(app) as client:
|
||||
def client(test_app):
|
||||
"""Create a test client using the test app with in-memory dependencies."""
|
||||
with TestClient(test_app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_root(client):
|
||||
response = client.get("/")
|
||||
response = client.get("/agent/api")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -22,7 +22,9 @@ def test_health(client):
|
||||
|
||||
|
||||
def test_chat(client):
|
||||
response = client.post("/chat", json={"messages": "Hello, how are you?"})
|
||||
response = client.post(
|
||||
"/chat", json={"messages": "Hello, how are you?"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() is not None
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from naliiabot.bot.factories.llm_factory import LLMFactory
|
||||
from naliiabot.bot.exceptions import NotFoundProviderError
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_deepseek import ChatDeepSeek
|
||||
|
||||
|
||||
def test_llm_factory():
|
||||
|
||||
antrophicModel = LLMFactory("anthropic").get_model()
|
||||
assert isinstance(antrophicModel, ChatAnthropic) is True
|
||||
|
||||
deepseekModel = LLMFactory("deepseek").get_model()
|
||||
assert isinstance(deepseekModel, ChatDeepSeek)
|
||||
def test_llm_factory(monkeypatch, mock_chat_anthropic, mock_chat_deepseek):
|
||||
"""Test que LLMFactory retorna el tipo correcto de modelo según el provider."""
|
||||
|
||||
# Mock ChatAnthropic para evitar necesitar API key
|
||||
monkeypatch.setattr(
|
||||
"naliiabot.bot.factories.llm_factory.ChatAnthropic",
|
||||
Mock(return_value=mock_chat_anthropic)
|
||||
)
|
||||
|
||||
# Mock ChatDeepSeek para evitar necesitar API key
|
||||
monkeypatch.setattr(
|
||||
"naliiabot.bot.factories.llm_factory.ChatDeepSeek",
|
||||
Mock(return_value=mock_chat_deepseek)
|
||||
)
|
||||
|
||||
# Test Anthropic
|
||||
anthropic_model = LLMFactory("anthropic").get_model()
|
||||
assert isinstance(anthropic_model, Mock)
|
||||
assert anthropic_model == mock_chat_anthropic
|
||||
|
||||
# Test DeepSeek
|
||||
deepseek_model = LLMFactory("deepseek").get_model()
|
||||
assert isinstance(deepseek_model, Mock)
|
||||
assert deepseek_model == mock_chat_deepseek
|
||||
|
||||
|
||||
def test_get_model_raises_exception_when_provider_not_found():
|
||||
|
||||
@@ -18,15 +18,20 @@ class TestWebhookChatBot:
|
||||
"""
|
||||
mock_agent = SimpleNamespace()
|
||||
mock_agent.ainvoke = AsyncMock(
|
||||
return_value={"messages": [SimpleNamespace(content="Respuesta mock")]}
|
||||
return_value={
|
||||
"messages": [SimpleNamespace(content="Respuesta mock")]
|
||||
}
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_agent] = lambda: mock_agent
|
||||
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://localhost:8010"
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://localhost:8010",
|
||||
) as client:
|
||||
response = await client.post("/webhook", json=send_message_payload)
|
||||
response = await client.post(
|
||||
"/webhook", json=send_message_payload
|
||||
)
|
||||
|
||||
app.dependency_overrides.pop(get_agent, None)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user