Files
smart-support/backend/tests/unit/test_escalation.py
Yaojia Wang 1050df780d feat: complete phase 2 -- multi-agent routing, interrupt TTL, escalation, templates
- Intent classification with LLM structured output (single/multi/ambiguous)
- Discount agent with apply_discount and generate_coupon tools
- Interrupt manager with 30-min TTL auto-expiration and retry prompts
- Webhook escalation module with exponential backoff retry (max 3)
- Three vertical industry templates (e-commerce, SaaS, fintech)
- Template loading in AgentRegistry
- Enhanced supervisor prompt with dynamic agent descriptions
- 153 tests passing, 90.18% coverage
2026-03-30 21:04:39 +02:00

170 lines
5.8 KiB
Python

"""Tests for app.escalation module."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from app.escalation import (
EscalationPayload,
EscalationResult,
NoOpEscalator,
WebhookEscalator,
)
def _make_payload(**kwargs) -> EscalationPayload:
defaults = {
"thread_id": "t1",
"reason": "Agent cannot resolve",
"conversation_summary": "User asked about refund policy",
}
defaults.update(kwargs)
return EscalationPayload(**defaults)
@pytest.mark.unit
class TestEscalationPayload:
def test_frozen(self) -> None:
payload = _make_payload()
with pytest.raises(Exception):
payload.thread_id = "t2" # type: ignore[misc]
def test_default_metadata(self) -> None:
payload = _make_payload()
assert payload.metadata == {}
def test_model_dump(self) -> None:
payload = _make_payload(metadata={"key": "val"})
data = payload.model_dump()
assert data["thread_id"] == "t1"
assert data["metadata"] == {"key": "val"}
@pytest.mark.unit
class TestEscalationResult:
def test_frozen(self) -> None:
result = EscalationResult(success=True, status_code=200, attempts=1, error=None)
assert result.success
assert result.status_code == 200
@pytest.mark.unit
class TestWebhookEscalator:
@pytest.mark.asyncio
async def test_empty_url_returns_failure(self) -> None:
escalator = WebhookEscalator(url="", max_retries=3)
result = await escalator.escalate(_make_payload())
assert not result.success
assert result.attempts == 0
assert "not configured" in result.error
@pytest.mark.asyncio
async def test_successful_post(self) -> None:
mock_response = AsyncMock()
mock_response.status_code = 200
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with patch("app.escalation.httpx.AsyncClient", return_value=mock_client):
escalator = WebhookEscalator(url="https://example.com/hook")
result = await escalator.escalate(_make_payload())
assert result.success
assert result.status_code == 200
assert result.attempts == 1
@pytest.mark.asyncio
async def test_retry_on_server_error(self) -> None:
fail_response = AsyncMock()
fail_response.status_code = 500
success_response = AsyncMock()
success_response.status_code = 200
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=[fail_response, fail_response, success_response])
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
):
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=3)
result = await escalator.escalate(_make_payload())
assert result.success
assert result.attempts == 3
@pytest.mark.asyncio
async def test_all_retries_exhausted(self) -> None:
fail_response = AsyncMock()
fail_response.status_code = 500
mock_client = AsyncMock()
mock_client.post = AsyncMock(return_value=fail_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
):
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=3)
result = await escalator.escalate(_make_payload())
assert not result.success
assert result.attempts == 3
assert "500" in result.error
@pytest.mark.asyncio
async def test_timeout_error(self) -> None:
mock_client = AsyncMock()
mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
):
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=2)
result = await escalator.escalate(_make_payload())
assert not result.success
assert "timed out" in result.error
@pytest.mark.asyncio
async def test_request_error(self) -> None:
mock_client = AsyncMock()
mock_client.post = AsyncMock(
side_effect=httpx.RequestError("connection refused")
)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
with (
patch("app.escalation.httpx.AsyncClient", return_value=mock_client),
patch("app.escalation.asyncio.sleep", new_callable=AsyncMock),
):
escalator = WebhookEscalator(url="https://example.com/hook", max_retries=1)
result = await escalator.escalate(_make_payload())
assert not result.success
@pytest.mark.unit
class TestNoOpEscalator:
@pytest.mark.asyncio
async def test_returns_disabled(self) -> None:
escalator = NoOpEscalator()
result = await escalator.escalate(_make_payload())
assert not result.success
assert result.attempts == 0
assert "disabled" in result.error.lower()