Compare commits

..

6 Commits

Author SHA1 Message Date
891df5e82b fix: simplify webhook test by removing unused fixtures and mocks 2026-06-18 16:26:37 -05:00
761253a056 fix: webhook test modernization and production bug fix
- Fix production bug in send_whatsapp_message: access 'instance_url' instead
  of 'server_url' to match SendMessageScheme TypedDict definition
- Update webhook test to use test_app with in-memory dependencies
- Mock send_whatsapp_message to avoid real HTTP calls in tests
- Add assertions to verify correct parameters passed to send_whatsapp_message

Bug: send_whatsapp_message accessed request_data['server_url'] but
SendMessageScheme defines field as 'instance_url', causing KeyError when
webhook tries to send WhatsApp messages.
2026-06-10 16:30:28 -05:00
d328d801f4 fix: mock LLM clients in test_llm_factory to avoid API key requirement
- Add mock_chat_anthropic and mock_chat_deepseek fixtures to conftest.py
- Update test_llm_factory to use monkeypatch and mock fixtures
- Import ChatAnthropic and ChatDeepSeek in conftest.py for spec creation
- Tests now pass without requiring ANTHROPIC_API_KEY or DEEPSEEK_API_KEY
- Execution time reduced from failing after 0.12s to passing in 0.01s

This change allows factory tests to run in isolation without external
dependencies, making them consistent with the API test approach and
suitable for any development environment or CI/CD pipeline.
2026-06-10 16:16:14 -05:00
34053b3fc1 fix: use in-memory checkpointer for API tests to avoid database dependency
- Replace PostgreSQL AsyncPostgresSaver with MemorySaver in test fixtures
- Create test_app fixture with test_lifespan using in-memory dependencies
- Mock LLM model to avoid requiring API keys during tests
- Update test_api.py to use test_app fixture instead of production app
- Fix test_root endpoint URL to match root_path configuration
- All API tests now pass without requiring external services (0.06s vs 30s+ timeout)

This change allows tests to run in isolation without PostgreSQL or API keys,
making them faster and more reliable for CI/CD and local development.
2026-06-10 16:06:10 -05:00
7fa4d12e30 fix: test 2026-06-10 15:37:37 -05:00
8dffd91bd7 fix: url hook 2026-06-10 15:34:32 -05:00
6 changed files with 201 additions and 88 deletions

View File

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

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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():
def test_llm_factory(monkeypatch, mock_chat_anthropic, mock_chat_deepseek):
"""Test que LLMFactory retorna el tipo correcto de modelo según el provider."""
antrophicModel = LLMFactory("anthropic").get_model()
assert isinstance(antrophicModel, ChatAnthropic) is True
# Mock ChatAnthropic para evitar necesitar API key
monkeypatch.setattr(
"naliiabot.bot.factories.llm_factory.ChatAnthropic",
Mock(return_value=mock_chat_anthropic)
)
deepseekModel = LLMFactory("deepseek").get_model()
assert isinstance(deepseekModel, ChatDeepSeek)
# 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():

View File

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