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.
This commit is contained in:
2026-06-10 16:30:28 -05:00
parent d328d801f4
commit 761253a056
3 changed files with 70 additions and 41 deletions

View File

@@ -15,7 +15,7 @@ async def send_whatsapp_message(request_data: SendMessageScheme):
Envía un mensaje de texto a través de la API de WhatsApp. Envía un mensaje de texto a través de la API de WhatsApp.
""" """
instance_url = request_data["server_url"] instance_url = request_data["instance_url"]
instance_name = request_data["instance_name"] instance_name = request_data["instance_name"]
apikey = request_data["apikey"] apikey = request_data["apikey"]
jid = request_data["jid"] jid = request_data["jid"]

View File

@@ -15,7 +15,9 @@ from naliiabot.bot.prompts.load_prompt import get_prompt_template
from naliiabot.bot.tools.naliia_tools import NaliiaTools from naliiabot.bot.tools.naliia_tools import NaliiaTools
import naliiabotapi.api.dependencies as deps import naliiabotapi.api.dependencies as deps
from naliiabotapi.api.v1.endpoints.chat import router as chat_router 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,
)
@asynccontextmanager @asynccontextmanager
@@ -23,31 +25,33 @@ async def test_lifespan(app: FastAPI):
"""Test lifespan that uses in-memory checkpointer instead of PostgreSQL.""" """Test lifespan that uses in-memory checkpointer instead of PostgreSQL."""
# Use in-memory checkpointer for tests # Use in-memory checkpointer for tests
checkpointer = MemorySaver() checkpointer = MemorySaver()
# Use a mock model for tests to avoid needing API keys # Use a mock model for tests to avoid needing API keys
# The mock needs to return proper AIMessage objects # The mock needs to return proper AIMessage objects
mock_model = MagicMock() mock_model = MagicMock()
def mock_invoke(*args, **kwargs): def mock_invoke(*args, **kwargs):
"""Mock invoke that returns a proper AIMessage.""" """Mock invoke that returns a proper AIMessage."""
return AIMessage(content="This is a test response from the mock model.") return AIMessage(
content="This is a test response from the mock model."
)
mock_model.invoke = mock_invoke mock_model.invoke = mock_invoke
mock_model.bind_tools = Mock(return_value=mock_model) mock_model.bind_tools = Mock(return_value=mock_model)
naliia_prompt = get_prompt_template("NALIIA_PROMPT") naliia_prompt = get_prompt_template("NALIIA_PROMPT")
naliia_tools = NaliiaTools().get_tools() naliia_tools = NaliiaTools().get_tools()
config = AgentConfig(system_prompt=naliia_prompt) config = AgentConfig(system_prompt=naliia_prompt)
deps.agent_instance = Agent( deps.agent_instance = Agent(
model=mock_model, model=mock_model,
config=config, config=config,
checkpointer=checkpointer, checkpointer=checkpointer,
tools=naliia_tools, tools=naliia_tools,
) )
yield yield
# Cleanup # Cleanup
deps.agent_instance = None deps.agent_instance = None
@@ -62,18 +66,18 @@ def test_app():
root_path="/agent/api", root_path="/agent/api",
lifespan=test_lifespan, lifespan=test_lifespan,
) )
app.include_router(chat_router) app.include_router(chat_router)
app.include_router(chat_hook_router) app.include_router(chat_hook_router)
@app.get("/") @app.get("/")
def read_root(): def read_root():
return {"Hello": "World"} return {"Hello": "World"}
@app.get("/health") @app.get("/health")
def health_check(): def health_check():
return {"status": "ok"} return {"status": "ok"}
return app return app
@@ -83,7 +87,7 @@ async def async_client(test_app):
async with AsyncClient( async with AsyncClient(
transport=ASGITransport(app=test_app), transport=ASGITransport(app=test_app),
base_url="http://localhost:8010" base_url="http://localhost:8010",
) as client: ) as client:
yield client yield client
@@ -110,9 +114,7 @@ def mock_model():
def default_config(): def default_config():
"""Configuración de test predecible.""" """Configuración de test predecible."""
return AgentConfig( return AgentConfig(
system_prompt="Test prompt", system_prompt="Test prompt", max_iterations=5, timeout_seconds=10.0
max_iterations=5,
timeout_seconds=10.0
) )
@@ -126,9 +128,7 @@ def agent(mock_model, default_config):
def agent_with_tool(mock_model, default_config, mock_tool): def agent_with_tool(mock_model, default_config, mock_tool):
"""Agente con una herramienta mock.""" """Agente con una herramienta mock."""
return Agent( return Agent(
model=mock_model, model=mock_model, config=default_config, tools=[mock_tool]
config=default_config,
tools=[mock_tool]
) )
@@ -150,8 +150,10 @@ def send_message_payload():
"key": { "key": {
"remoteJid": "5512992544490@s.whatsapp.net", "remoteJid": "5512992544490@s.whatsapp.net",
"remoteJidAlt": "5512992544490@s.whatsapp.net", "remoteJidAlt": "5512992544490@s.whatsapp.net",
"fromMe": False, "id": "AC4367597BDC50537133B9C8848B2E62", "fromMe": False,
"participant": "", "addressingMode": "lid" "id": "AC4367597BDC50537133B9C8848B2E62",
"participant": "",
"addressingMode": "lid",
}, },
"pushName": "Alejandro", "pushName": "Alejandro",
"status": "DELIVERY_ACK", "status": "DELIVERY_ACK",
@@ -172,12 +174,12 @@ def send_message_payload():
"6": 172, "6": 172,
"7": 243, "7": 243,
"8": 27, "8": 27,
"9": 98 "9": 98,
}, },
"senderTimestamp": { "senderTimestamp": {
"low": 1772156772, "low": 1772156772,
"high": 0, "high": 0,
"unsigned": "true" "unsigned": "true",
}, },
"recipientKeyHash": { "recipientKeyHash": {
"0": 16, "0": 16,
@@ -189,13 +191,13 @@ def send_message_payload():
"6": 24, "6": 24,
"7": 225, "7": 225,
"8": 244, "8": 244,
"9": 126 "9": 126,
}, },
"recipientTimestamp": { "recipientTimestamp": {
"low": 1772155597, "low": 1772155597,
"high": 0, "high": 0,
"unsigned": True "unsigned": True,
} },
}, },
"deviceListMetadataVersion": 2, "deviceListMetadataVersion": 2,
"messageSecret": { "messageSecret": {
@@ -230,18 +232,20 @@ def send_message_payload():
"28": 87, "28": 87,
"29": 176, "29": 176,
"30": 135, "30": 135,
"31": 62 "31": 62,
}}}, },
},
},
"messageType": "conversation", "messageType": "conversation",
"messageTimestamp": 1772168493, "messageTimestamp": 1772168493,
"instanceId": "75a877f1-d772-422b-a6f1-6ac470af5663", "instanceId": "75a877f1-d772-422b-a6f1-6ac470af5663",
"source": "android" "source": "android",
}, },
"destination": "http://192.168.58.110:8010/webhook", "destination": "http://192.168.58.110:8010/webhook",
"date_time": "2026-02-27T02:01:34.178Z", "date_time": "2026-02-27T02:01:34.178Z",
"sender": "573016859278@s.whatsapp.net", "sender": "573016859278@s.whatsapp.net",
"server_url": "http://localhost:8080", "server_url": "http://localhost:8080",
"apikey": "98B6B820AFC3-4544-9A4C-011B0719C2D1" "apikey": "98B6B820AFC3-4544-9A4C-011B0719C2D1",
} }

View File

@@ -1,8 +1,7 @@
import pytest import pytest
from unittest.mock import AsyncMock from unittest.mock import AsyncMock, Mock
from types import SimpleNamespace from types import SimpleNamespace
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from naliiabotapi.main import app
from naliiabotapi.api.dependencies import get_agent from naliiabotapi.api.dependencies import get_agent
@@ -10,25 +9,51 @@ class TestWebhookChatBot:
"""Tests for WebhookChatBot.""" """Tests for WebhookChatBot."""
@pytest.mark.anyio @pytest.mark.anyio
async def tests_webhook_success(self, send_message_payload): async def tests_webhook_success(
self, send_message_payload, test_app, monkeypatch
):
"""Test that the webhook endpoint returns a successful response. """Test that the webhook endpoint returns a successful response.
Se crea un `mock_agent` con `ainvoke` mockeado y se inyecta Usa test_app con in-memory dependencies y mockea send_whatsapp_message
mediante `app.dependency_overrides` antes de realizar la petición. para evitar llamadas HTTP reales durante el test.
""" """
# Mock del agente
mock_agent = SimpleNamespace() mock_agent = SimpleNamespace()
mock_agent.ainvoke = AsyncMock( 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 # Mock de send_whatsapp_message para evitar llamadas HTTP reales
mock_send_whatsapp = AsyncMock(return_value={"status": "sent"})
monkeypatch.setattr(
"naliiabotapi.api.v1.webhooks.chat_hook.send_whatsapp_message",
mock_send_whatsapp,
)
# Override de dependencia del agente
test_app.dependency_overrides[get_agent] = lambda: mock_agent
async with AsyncClient( async with AsyncClient(
transport=ASGITransport(app=app), base_url="http://localhost:8010" transport=ASGITransport(app=test_app),
base_url="http://localhost:8010",
) as client: ) 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) # Cleanup
test_app.dependency_overrides.clear()
# Verificaciones
assert response.status_code == 200 assert response.status_code == 200
assert response.json().get("reply") == "Respuesta mock" response_data = response.json()
assert response_data.get("reply") == "Respuesta mock"
assert response_data.get("status") == "sent"
# Verificar que se llamó al mock del agente
mock_agent.ainvoke.assert_called_once()
# Verificar que se llamó a send_whatsapp_message con los parámetros correctos
mock_send_whatsapp.assert_called_once()
whatsapp_call = mock_send_whatsapp.call_args[0][0]
assert whatsapp_call["instance_url"] == "http://localhost:8080"
assert whatsapp_call["instance_name"] == "ALEJANDRO AYALA"
assert whatsapp_call["text"] == "Respuesta mock"