Feat/Add API Trigger and Webhooks in Agent Builder (#83)

* feat: add api trigger node for agent runs

* feat: add webhook node

* Execute webhook nodes post workflow run

* Add hint to go to API keys
This commit is contained in:
Abhishek 2025-12-22 14:08:30 +05:30 committed by GitHub
parent 4ddb144dd0
commit 55b727a872
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3667 additions and 494 deletions

View file

@ -1,136 +1,330 @@
"""Tests for webhook execution in run_integrations.py."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from api.tasks.run_integrations import _process_slack_integration
from api.tasks.run_integrations import (
_build_auth_header,
_build_render_context,
_execute_webhook_node,
)
@pytest.fixture(autouse=True)
def mock_logger():
"""Mock the logger for all tests."""
with patch("api.tasks.run_integrations.logger") as mock_logger:
# Mock the bind method to return the logger itself
mock_logger.bind.return_value = mock_logger
yield mock_logger
with patch("api.tasks.run_integrations.logger") as mock_log:
mock_log.bind.return_value = mock_log
yield mock_log
@pytest.mark.asyncio
async def test_slack_integration_with_template():
"""Test that Slack integration uses render_template correctly."""
# Mock integration
mock_integration = MagicMock()
mock_integration.id = 1
mock_integration.organisation_id = 123
mock_integration.connection_details = {
"connection_config": {"incoming_webhook.url": "https://hooks.slack.com/test"}
}
class TestBuildAuthHeader:
"""Tests for _build_auth_header function."""
# Mock gathered context
gathered_context = {
"call_disposition": "XFER",
"mapped_call_disposition": "XFER", # Required for Slack integration to proceed
"call_duration": "300",
"agent_name": "Alex",
}
def test_bearer_token(self):
"""Test bearer token auth header."""
credential = MagicMock()
credential.credential_type = "bearer_token"
credential.credential_data = {"token": "my-secret-token"}
# Mock db_client
with patch("api.tasks.run_integrations.db_client") as mock_db_client:
# Mock message template configuration
mock_db_client.get_configuration_value = AsyncMock(
return_value={
"slack": {
"DISPOSITION_CODE": "Agent: {{agent_name}}\\nDisposition: {{call_disposition}}\\nDuration: {{call_duration}}s"
}
}
result = _build_auth_header(credential)
assert result == {"Authorization": "Bearer my-secret-token"}
def test_api_key(self):
"""Test API key auth header."""
credential = MagicMock()
credential.credential_type = "api_key"
credential.credential_data = {"header_name": "X-API-Key", "api_key": "key123"}
result = _build_auth_header(credential)
assert result == {"X-API-Key": "key123"}
def test_api_key_default_header(self):
"""Test API key with default header name."""
credential = MagicMock()
credential.credential_type = "api_key"
credential.credential_data = {"api_key": "key123"}
result = _build_auth_header(credential)
assert result == {"X-API-Key": "key123"}
def test_basic_auth(self):
"""Test basic auth header."""
credential = MagicMock()
credential.credential_type = "basic_auth"
credential.credential_data = {"username": "user", "password": "pass"}
result = _build_auth_header(credential)
# base64 of "user:pass" is "dXNlcjpwYXNz"
assert result == {"Authorization": "Basic dXNlcjpwYXNz"}
def test_custom_header(self):
"""Test custom header auth."""
credential = MagicMock()
credential.credential_type = "custom_header"
credential.credential_data = {
"header_name": "X-Custom-Auth",
"header_value": "custom-value",
}
result = _build_auth_header(credential)
assert result == {"X-Custom-Auth": "custom-value"}
def test_unknown_type(self):
"""Test unknown credential type returns empty dict."""
credential = MagicMock()
credential.credential_type = "unknown"
credential.credential_data = {}
result = _build_auth_header(credential)
assert result == {}
class TestBuildRenderContext:
"""Tests for _build_render_context function."""
def test_basic_context(self):
"""Test building render context from workflow run."""
workflow_run = MagicMock()
workflow_run.id = 123
workflow_run.name = "WR-TEST-001"
workflow_run.workflow_id = 456
workflow_run.workflow.name = "Test Workflow"
workflow_run.initial_context = {"phone_number": "+1234567890"}
workflow_run.gathered_context = {
"customer_name": "John",
"mapped_call_disposition": "QUALIFIED",
}
workflow_run.usage_info = {"call_duration_seconds": 120}
workflow_run.completed_at = None
result = _build_render_context(workflow_run)
assert result["workflow_run_id"] == 123
assert result["workflow_run_name"] == "WR-TEST-001"
assert result["workflow_id"] == 456
assert result["workflow_name"] == "Test Workflow"
assert result["initial_context"]["phone_number"] == "+1234567890"
assert result["gathered_context"]["customer_name"] == "John"
assert result["cost_info"]["call_duration_seconds"] == 120
assert result["disposition_code"] == "QUALIFIED"
def test_empty_contexts(self):
"""Test with empty/None contexts."""
workflow_run = MagicMock()
workflow_run.id = 1
workflow_run.name = "Test"
workflow_run.workflow_id = 1
workflow_run.workflow.name = "Workflow"
workflow_run.initial_context = None
workflow_run.gathered_context = None
workflow_run.usage_info = None
workflow_run.completed_at = None
result = _build_render_context(workflow_run)
assert result["initial_context"] == {}
assert result["gathered_context"] == {}
assert result["cost_info"] == {}
assert result["disposition_code"] is None
class TestExecuteWebhookNode:
"""Tests for _execute_webhook_node function."""
@pytest.mark.asyncio
async def test_disabled_webhook_skipped(self):
"""Test that disabled webhooks are skipped."""
webhook_data = {"name": "Test Webhook", "enabled": False}
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
# Mock aiohttp session
with patch(
"api.tasks.run_integrations.aiohttp.ClientSession"
) as mock_session_class:
mock_response = MagicMock()
mock_response.status = 200
assert result is True # Returns True for skipped webhooks
mock_session = MagicMock()
mock_session.__aenter__.return_value = mock_session
mock_session.__aexit__.return_value = AsyncMock()
@pytest.mark.asyncio
async def test_missing_url_returns_false(self):
"""Test that missing endpoint URL returns False."""
webhook_data = {"name": "Test Webhook", "enabled": True, "endpoint_url": None}
mock_post = MagicMock()
mock_post.__aenter__.return_value = mock_response
mock_post.__aexit__.return_value = AsyncMock()
mock_session.post.return_value = mock_post
mock_session_class.return_value = mock_session
# Call the function
await _process_slack_integration(mock_integration, gathered_context)
# Verify the message was formatted correctly
mock_session.post.assert_called_once()
call_args = mock_session.post.call_args
# Check the webhook URL
assert call_args[0][0] == "https://hooks.slack.com/test"
# Check the message content
json_data = call_args[1]["json"]
# Check that the template was rendered correctly
expected_text = "Agent: Alex\nDisposition: XFER\nDuration: 300s"
assert json_data["text"] == expected_text
@pytest.mark.asyncio
async def test_slack_integration_with_missing_template_vars():
"""Test template rendering with missing variables."""
# Mock integration
mock_integration = MagicMock()
mock_integration.id = 1
mock_integration.organisation_id = 123
mock_integration.connection_details = {
"connection_config": {"incoming_webhook.url": "https://hooks.slack.com/test"}
}
# Mock gathered context with missing values
gathered_context = {
"call_disposition": "XFER",
"mapped_call_disposition": "XFER", # Required for Slack integration to proceed
# call_duration is missing
}
# Mock db_client
with patch("api.tasks.run_integrations.db_client") as mock_db_client:
# Mock message template configuration with fallback
mock_db_client.get_configuration_value = AsyncMock(
return_value={
"slack": {
"DISPOSITION_CODE": "Disposition: {{call_disposition}}\\nDuration: {{call_duration | fallback:N/A}}"
}
}
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
# Mock aiohttp session
with patch(
"api.tasks.run_integrations.aiohttp.ClientSession"
) as mock_session_class:
mock_response = MagicMock()
mock_response.status = 200
assert result is False
mock_session = MagicMock()
mock_session.__aenter__.return_value = mock_session
mock_session.__aexit__.return_value = AsyncMock()
@pytest.mark.asyncio
async def test_successful_post_request(self):
"""Test successful POST webhook execution."""
webhook_data = {
"name": "CRM Sync",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"payload_template": {
"call_id": "{{workflow_run_id}}",
"phone": "{{initial_context.phone_number}}",
},
}
mock_post = MagicMock()
mock_post.__aenter__.return_value = mock_response
mock_post.__aexit__.return_value = AsyncMock()
render_context = {
"workflow_run_id": 123,
"initial_context": {"phone_number": "+1234567890"},
}
mock_session.post.return_value = mock_post
mock_session_class.return_value = mock_session
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=None)
# Call the function
await _process_slack_integration(mock_integration, gathered_context)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
# Check that the template was rendered with fallback
json_data = mock_session.post.call_args[1]["json"]
expected_text = "Disposition: XFER\nDuration: N/A"
assert json_data["text"] == expected_text
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context=render_context,
organization_id=1,
)
assert result is True
# Verify the request was made correctly
mock_client_instance.request.assert_called_once()
call_kwargs = mock_client_instance.request.call_args[1]
assert call_kwargs["method"] == "POST"
assert call_kwargs["url"] == "https://api.example.com/webhook"
assert call_kwargs["json"] == {
"call_id": "123",
"phone": "+1234567890",
}
@pytest.mark.asyncio
async def test_webhook_with_credential(self):
"""Test webhook execution with credential auth."""
webhook_data = {
"name": "Authenticated Webhook",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"credential_uuid": "cred-123",
"payload_template": {},
}
mock_credential = MagicMock()
mock_credential.name = "API Key"
mock_credential.credential_type = "bearer_token"
mock_credential.credential_data = {"token": "secret-token"}
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=mock_credential)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
assert result is True
# Verify auth header was included
call_kwargs = mock_client_instance.request.call_args[1]
assert call_kwargs["headers"]["Authorization"] == "Bearer secret-token"
@pytest.mark.asyncio
async def test_webhook_with_custom_headers(self):
"""Test webhook execution with custom headers."""
webhook_data = {
"name": "Custom Headers Webhook",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"custom_headers": [
{"key": "X-Source", "value": "dograh"},
{"key": "X-Workflow", "value": "test"},
],
"payload_template": {},
}
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=None)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
assert result is True
# Verify custom headers were included
call_kwargs = mock_client_instance.request.call_args[1]
assert call_kwargs["headers"]["X-Source"] == "dograh"
assert call_kwargs["headers"]["X-Workflow"] == "test"
@pytest.mark.asyncio
async def test_webhook_http_error(self):
"""Test webhook execution with HTTP error."""
import httpx
webhook_data = {
"name": "Failing Webhook",
"enabled": True,
"http_method": "POST",
"endpoint_url": "https://api.example.com/webhook",
"payload_template": {},
}
with patch("api.tasks.run_integrations.db_client") as mock_db:
mock_db.get_credential_by_uuid = AsyncMock(return_value=None)
with patch("api.tasks.run_integrations.httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
mock_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError(
"Server Error",
request=MagicMock(),
response=mock_response,
)
)
mock_client_instance = AsyncMock()
mock_client_instance.request = AsyncMock(return_value=mock_response)
mock_client.return_value.__aenter__.return_value = mock_client_instance
result = await _execute_webhook_node(
webhook_data=webhook_data,
render_context={},
organization_id=1,
)
assert result is False