ForcePilot/backend/test/unit/channel/message/test_dispatcher.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

590 lines
22 KiB
Python

from __future__ import annotations
import asyncio
import json
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException, Request, Response, status
from yuxi.channel.config import ChannelConfigManager
from yuxi.channel.constants import InboundRejectionReason
from yuxi.channel.lifecycle.manager import ChannelLifecycleManager
from yuxi.channel.message.dedupe import MessageDeduper
from yuxi.channel.message.dispatcher import ChannelGateway
from yuxi.channel.middlewares.inbound import (
CreateRunMiddleware,
DedupeMiddleware,
RouteMiddleware,
SecurityMiddleware,
SessionMiddleware,
TransactionMiddleware,
)
from yuxi.channel.middlewares.registry import InboundMiddlewareRegistry
from yuxi.channel.plugins.protocol import BindingRoute, ChannelMeta, InboundMessage
from yuxi.channel.routing.router import BindingRouter
from yuxi.channel.security.policy import SecurityCheckResult
from yuxi.channel.session.manager import SessionManager
@pytest.fixture
def redis_mock():
pubsub = MagicMock(
subscribe=AsyncMock(),
unsubscribe=AsyncMock(),
close=AsyncMock(),
listen=MagicMock(),
)
return MagicMock(
pubsub=MagicMock(return_value=pubsub),
set=AsyncMock(),
exists=AsyncMock(),
setex=AsyncMock(),
)
@pytest.fixture
def plugin_mock():
plugin = MagicMock()
plugin.get_meta.return_value = ChannelMeta(channel_type="feishu", display_name="Feishu")
plugin.validate_webhook = AsyncMock(return_value=True)
plugin.preprocess_event = AsyncMock(return_value=(True, None, None))
plugin.normalize_inbound = AsyncMock(
return_value=InboundMessage(channel_type="feishu", account_id="acc-1", content="hi")
)
plugin.build_webhook_response = AsyncMock(return_value=None)
plugin.download_attachment = AsyncMock()
plugin.health_check = AsyncMock(return_value=MagicMock(healthy=True))
plugin.on_config_changed = AsyncMock()
plugin.on_account_removed = AsyncMock()
plugin.on_channel_enabled = AsyncMock()
plugin.on_channel_disabled = AsyncMock()
plugin.run_startup_maintenance = AsyncMock()
plugin.on_transport_message = AsyncMock(
return_value=InboundMessage(channel_type="feishu", account_id="acc-1", content="hi")
)
plugin.create_transport = AsyncMock(return_value=None)
plugin.resolve_account_id_from_webhook = AsyncMock(return_value=None)
plugin.transform_inbound = AsyncMock(return_value={})
return plugin
@pytest.fixture
def registry_mock(plugin_mock):
registry = MagicMock()
registry.get_plugin.return_value = plugin_mock
return registry
@pytest.fixture
def config_manager_mock():
cm = MagicMock(spec=ChannelConfigManager)
cm.get_config = AsyncMock(return_value={"channel_type": "feishu", "account_id": "acc-1", "enabled": True})
return cm
@pytest.fixture
def security_policy_mock():
policy = MagicMock()
policy.check = AsyncMock(return_value=SecurityCheckResult(allowed=True))
return policy
@pytest.fixture
def session_manager_mock():
sm = MagicMock(spec=SessionManager)
session = MagicMock()
session.session_key = "sk-1"
session.channel_metadata = {}
sm.resolve = AsyncMock(return_value=(session, None))
sm.record_message = AsyncMock()
return sm
@pytest.fixture
def binding_router_mock():
router = MagicMock(spec=BindingRouter)
router.resolve = AsyncMock(return_value=BindingRoute(agent_id="agent-1", session_key="sk-1", matched_by="default"))
router.invalidate_account_cache = AsyncMock(return_value=1)
return router
@pytest.fixture
def lifecycle_manager_mock():
lm = MagicMock(spec=ChannelLifecycleManager)
lm.start_all = AsyncMock()
lm.stop_all = AsyncMock()
lm.start_channel = AsyncMock()
lm.stop_channel = AsyncMock()
return lm
@pytest.fixture
def outbound_dispatcher_mock():
mock = AsyncMock()
mock._outbound_registry = MagicMock()
mock._outbound_registry.invalidate = MagicMock()
return mock
@pytest.fixture
def gateway(
registry_mock,
config_manager_mock,
session_manager_mock,
binding_router_mock,
outbound_dispatcher_mock,
security_policy_mock,
lifecycle_manager_mock,
deduper_mock,
):
inbound_registry = InboundMiddlewareRegistry()
gw = ChannelGateway(
registry=registry_mock,
config_manager=config_manager_mock,
session_manager=session_manager_mock,
binding_router=binding_router_mock,
outbound_dispatcher=outbound_dispatcher_mock,
security_policy=security_policy_mock,
lifecycle_manager=lifecycle_manager_mock,
inbound_registry=inbound_registry,
dedupe=deduper_mock,
)
inbound_registry.register(DedupeMiddleware(deduper_mock))
inbound_registry.register(SecurityMiddleware(security_policy_mock, deduper_mock))
inbound_registry.register(TransactionMiddleware(deduper_mock))
inbound_registry.register(SessionMiddleware(session_manager_mock))
inbound_registry.register(RouteMiddleware(binding_router_mock))
inbound_registry.register(
CreateRunMiddleware(
create_run=gw._create_agent_run,
record_message=session_manager_mock.record_message,
)
)
return gw
@pytest.fixture
def db_session_mock():
return MagicMock(commit=AsyncMock(), flush=AsyncMock())
@pytest.fixture
def deduper_mock():
d = MagicMock(spec=MessageDeduper)
d.is_processed = AsyncMock(return_value=False)
d.clear_processed = AsyncMock()
return d
@pytest.fixture
def pg_manager_mock(monkeypatch, db_session_mock):
@asynccontextmanager
async def ctx():
yield db_session_mock
mock = MagicMock()
mock.get_async_session_context = ctx
monkeypatch.setattr("yuxi.channel.middlewares.inbound.pg_manager", mock)
return mock
@pytest.fixture
def request_mock():
req = MagicMock(spec=Request)
req.state = MagicMock()
req.path_params = {}
req.query_params = MagicMock()
req.query_params.get = MagicMock(return_value="acc-1")
req.headers = {}
return req
async def test_start_and_stop_config_change_listener(gateway, redis_mock):
redis_mock.pubsub.return_value.listen = MagicMock(return_value=AsyncIterator([]))
with patch("yuxi.channel.message.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
await gateway.start_config_change_listener()
assert gateway._listen_task is not None
await gateway.stop_config_change_listener()
assert gateway._listen_task is None
async def test_listen_config_changes_skips_non_message_events(gateway, redis_mock):
redis_mock.pubsub.return_value.listen = MagicMock(
return_value=AsyncIterator(
[
{"type": "subscribe", "data": None},
{
"type": "message",
"data": json.dumps({"channel_type": "feishu", "account_id": "acc-1", "action": "updated"}),
},
],
raise_on_exhaustion=asyncio.CancelledError,
)
)
with patch("yuxi.channel.message.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with pytest.raises(asyncio.CancelledError):
await gateway._listen_config_changes()
gateway.binding_router.invalidate_account_cache.assert_awaited_once_with("feishu", "acc-1")
async def test_listen_config_changes_invalid_payload_is_ignored(gateway, redis_mock):
redis_mock.pubsub.return_value.listen = MagicMock(
return_value=AsyncIterator(
[
{"type": "message", "data": "not-json"},
{"type": "message", "data": json.dumps({"channel_type": "feishu"})},
],
raise_on_exhaustion=asyncio.CancelledError,
)
)
with patch("yuxi.channel.message.dispatcher.get_redis_client", AsyncMock(return_value=redis_mock)):
with pytest.raises(asyncio.CancelledError):
await gateway._listen_config_changes()
gateway.binding_router.invalidate_account_cache.assert_not_called()
async def test_handle_config_change_restarts_enabled_channel(gateway, plugin_mock, config_manager_mock):
await gateway._handle_config_change("feishu", "acc-1", "updated")
gateway.binding_router.invalidate_account_cache.assert_awaited_once_with("feishu", "acc-1")
gateway.lifecycle.stop_channel.assert_awaited_once_with("feishu", "acc-1")
gateway.lifecycle.start_channel.assert_awaited_once_with("feishu", "acc-1")
plugin_mock.on_config_changed.assert_awaited_once()
async def test_handle_config_change_disabled_channel_stops_only(gateway, plugin_mock):
await gateway._handle_config_change("feishu", "acc-1", "disabled")
gateway.lifecycle.stop_channel.assert_awaited_once_with("feishu", "acc-1")
gateway.lifecycle.start_channel.assert_not_awaited()
async def test_handle_config_change_missing_config_is_handled(gateway, config_manager_mock):
config_manager_mock.get_config.side_effect = RuntimeError("db down")
await gateway._handle_config_change("feishu", "acc-1", "updated")
gateway.lifecycle.stop_channel.assert_awaited_once_with("feishu", "acc-1")
gateway.lifecycle.start_channel.assert_not_awaited()
async def test_start_stop_all_channels(gateway):
await gateway.start_all_channels()
gateway.lifecycle.start_all.assert_awaited_once()
await gateway.stop_all_channels()
gateway.lifecycle.stop_all.assert_awaited_once()
async def test_stop_closes_listener_and_channels(gateway):
task = asyncio.create_task(asyncio.sleep(1))
gateway._listen_task = task
await gateway.stop()
assert task.cancelled() or task.done()
gateway.lifecycle.stop_all.assert_awaited_once()
async def test_handle_webhook_success(gateway, registry_mock, plugin_mock, request_mock, pg_manager_mock):
plugin_mock.normalize_inbound.return_value = InboundMessage(
channel_type="feishu",
account_id="acc-1",
content="hello",
channel_message_id="m-1",
sender_id="u-1",
)
with patch("yuxi.channel.message.dispatcher.create_agent_run_view", AsyncMock(return_value={"run_id": "run-1"})):
response = await gateway.handle_webhook("feishu", request_mock)
assert isinstance(response, Response)
assert response.status_code == status.HTTP_200_OK
plugin_mock.validate_webhook.assert_awaited_once_with(request_mock)
async def test_handle_webhook_unknown_channel(gateway, registry_mock):
registry_mock.get_plugin.return_value = None
with pytest.raises(HTTPException) as exc_info:
await gateway.handle_webhook("unknown", MagicMock())
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
async def test_handle_webhook_missing_account_id(gateway, plugin_mock, request_mock):
request_mock.query_params.get = MagicMock(return_value=None)
plugin_mock.resolve_account_id_from_webhook = AsyncMock(return_value=None)
with pytest.raises(HTTPException) as exc_info:
await gateway.handle_webhook("feishu", request_mock)
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
async def test_handle_webhook_config_not_found(gateway, config_manager_mock, request_mock):
config_manager_mock.get_config.side_effect = ValueError("missing")
with pytest.raises(HTTPException) as exc_info:
await gateway.handle_webhook("feishu", request_mock)
assert exc_info.value.status_code == status.HTTP_404_NOT_FOUND
async def test_handle_webhook_validation_failed(gateway, plugin_mock, request_mock):
plugin_mock.validate_webhook = AsyncMock(return_value=False)
with pytest.raises(HTTPException) as exc_info:
await gateway.handle_webhook("feishu", request_mock)
assert exc_info.value.status_code == status.HTTP_401_UNAUTHORIZED
async def test_handle_webhook_preprocess_early_response(gateway, plugin_mock, request_mock):
plugin_mock.preprocess_event = AsyncMock(return_value=(False, {"ok": True}, None))
response = await gateway.handle_webhook("feishu", request_mock)
assert response.status_code == status.HTTP_200_OK
assert json.loads(response.body) == {"ok": True}
async def test_handle_webhook_preprocess_no_continue_no_response(gateway, plugin_mock, request_mock):
plugin_mock.preprocess_event = AsyncMock(return_value=(False, None, None))
response = await gateway.handle_webhook("feishu", request_mock)
assert response.status_code == status.HTTP_200_OK
assert response.body == b""
async def test_handle_webhook_preprocess_exception(gateway, plugin_mock, request_mock):
plugin_mock.preprocess_event = AsyncMock(side_effect=ValueError("bad"))
with pytest.raises(HTTPException) as exc_info:
await gateway.handle_webhook("feishu", request_mock)
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
async def test_handle_webhook_normalize_exception(gateway, plugin_mock, request_mock):
plugin_mock.normalize_inbound = AsyncMock(side_effect=ValueError("bad"))
with pytest.raises(HTTPException) as exc_info:
await gateway.handle_webhook("feishu", request_mock)
assert exc_info.value.status_code == status.HTTP_400_BAD_REQUEST
async def test_handle_webhook_rejected_pairing_code(gateway, plugin_mock, security_policy_mock, request_mock):
plugin_mock.normalize_inbound.return_value = InboundMessage(
channel_type="feishu", account_id="acc-1", content="hi", channel_message_id="m-1"
)
security_policy_mock.check = AsyncMock(
return_value=SecurityCheckResult(
allowed=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
pairing_code="code-1",
)
)
plugin_mock.build_webhook_response = AsyncMock(return_value=None)
response = await gateway.handle_webhook("feishu", request_mock)
assert response.status_code == status.HTTP_200_OK
assert json.loads(response.body) == {"pairing_code": "code-1"}
async def test_build_webhook_response_challenge(gateway, plugin_mock):
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", raw_event={"challenge": "c-1"})
result = await gateway._build_webhook_response(plugin_mock, MagicMock(), inbound)
assert result == {"challenge": "c-1"}
async def test_build_webhook_response_plugin_override(gateway, plugin_mock):
plugin_mock.build_webhook_response = AsyncMock(return_value={"custom": True})
inbound = InboundMessage(channel_type="feishu", account_id="acc-1")
result = await gateway._build_webhook_response(plugin_mock, MagicMock(), inbound)
assert result == {"custom": True}
async def test_on_transport_message_dispatches(gateway, plugin_mock, pg_manager_mock):
plugin_mock.on_transport_message = AsyncMock(
return_value=InboundMessage(channel_type="feishu", account_id="acc-1", content="hi")
)
with patch("yuxi.channel.message.dispatcher.create_agent_run_view", AsyncMock(return_value={"run_id": "run-1"})):
await gateway.on_transport_message(b'{"text":"hi"}', "feishu", "acc-1")
plugin_mock.on_transport_message.assert_awaited_once()
async def test_on_transport_message_unknown_plugin(gateway, registry_mock):
registry_mock.get_plugin.return_value = None
await gateway.on_transport_message(b"x", "unknown", "acc-1")
async def test_on_transport_message_normalize_returns_none(gateway, plugin_mock):
plugin_mock.on_transport_message = AsyncMock(return_value=None)
await gateway.on_transport_message(b"x", "feishu", "acc-1")
async def test_process_inbound_duplicate(gateway, deduper_mock):
deduper_mock.is_processed.return_value = True
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1")
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is False
assert result.reason == InboundRejectionReason.DUPLICATE
deduper_mock.is_processed.assert_awaited_once_with("m-1")
async def test_process_inbound_unknown_channel(gateway, registry_mock, deduper_mock):
registry_mock.get_plugin.return_value = None
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1")
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is False
assert result.reason == InboundRejectionReason.UNKNOWN_CHANNEL
deduper_mock.clear_processed.assert_awaited_once_with("m-1")
async def test_process_inbound_config_error(gateway, config_manager_mock, deduper_mock):
config_manager_mock.get_config.side_effect = RuntimeError("db down")
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1")
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is False
assert result.reason == InboundRejectionReason.CONFIG_ERROR
deduper_mock.clear_processed.assert_awaited_once_with("m-1")
async def test_process_inbound_security_rate_limited(gateway, security_policy_mock, deduper_mock):
security_policy_mock.check = AsyncMock(
return_value=SecurityCheckResult(allowed=False, reason=InboundRejectionReason.RATE_LIMITED)
)
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1")
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is False
assert result.reason == InboundRejectionReason.RATE_LIMITED
deduper_mock.clear_processed.assert_awaited_once_with("m-1")
async def test_process_inbound_security_dm_pairing_required(gateway, security_policy_mock):
security_policy_mock.check = AsyncMock(
return_value=SecurityCheckResult(
allowed=False,
reason=InboundRejectionReason.DM_PAIRING_REQUIRED,
pairing_code="code-1",
)
)
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1")
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is False
assert result.reason == InboundRejectionReason.DM_PAIRING_REQUIRED
assert result.pairing_code == "code-1"
async def test_process_inbound_internal_error(gateway, session_manager_mock, pg_manager_mock, deduper_mock):
session_manager_mock.resolve = AsyncMock(side_effect=RuntimeError("boom"))
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1")
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is False
assert result.reason == InboundRejectionReason.INTERNAL_ERROR
assert deduper_mock.clear_processed.await_count >= 1
async def test_process_inbound_success(gateway, pg_manager_mock):
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", channel_message_id="m-1", sender_id="u-1")
with patch("yuxi.channel.message.dispatcher.create_agent_run_view", AsyncMock(return_value={"run_id": "run-1"})):
result = await gateway._process_inbound("feishu", inbound)
assert result.accepted is True
assert result.run_id == "run-1"
async def test_create_agent_run_uses_uuid5_thread(gateway, pg_manager_mock, db_session_mock):
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", content="hello")
route = BindingRoute(agent_id="agent-1", session_key="sk-1", matched_by="default")
session = MagicMock(session_key="sk-1", channel_metadata={})
with patch(
"yuxi.channel.message.dispatcher.create_agent_run_view", AsyncMock(return_value={"run_id": "run-1"})
) as mock_run:
await gateway._create_agent_run(session, route, inbound, db_session_mock)
mock_run.assert_awaited_once()
call_kwargs = mock_run.call_args.kwargs
assert call_kwargs["agent_id"] == "agent-1"
assert call_kwargs["query"] == "hello"
assert call_kwargs["current_uid"] == "channel:feishu:acc-1"
assert call_kwargs["thread_id"]
async def test_extract_image_content_no_image(gateway):
inbound = InboundMessage(channel_type="feishu", account_id="acc-1", media=[])
result = await gateway._extract_image_content(inbound)
assert result is None
async def test_extract_image_content_success(gateway, plugin_mock):
from yuxi.channel.plugins.protocol import InboundMedia
plugin_mock.download_attachment = AsyncMock(return_value=(b"data", "image/png"))
inbound = InboundMessage(
channel_type="feishu",
account_id="acc-1",
media=[InboundMedia(media_type="image", file_name="pic.png")],
)
with patch(
"yuxi.channel.message.dispatcher.process_uploaded_image",
return_value={"success": True, "image_content": "base64"},
):
result = await gateway._extract_image_content(inbound)
assert result == "base64"
async def test_extract_image_content_fallback_base64(gateway, plugin_mock):
from yuxi.channel.plugins.protocol import InboundMedia
plugin_mock.download_attachment = AsyncMock(return_value=(b"data", "image/png"))
inbound = InboundMessage(
channel_type="feishu",
account_id="acc-1",
media=[InboundMedia(media_type="image")],
)
with patch("yuxi.channel.message.dispatcher.process_uploaded_image", return_value={"success": False}):
result = await gateway._extract_image_content(inbound)
assert result == "ZGF0YQ=="
async def test_health_check_unknown_channel(gateway, registry_mock):
registry_mock.get_plugin.return_value = None
result = await gateway.health_check("unknown", "acc-1")
assert result.healthy is False
assert result.last_error == "Unknown channel type"
async def test_health_check_plugin_error(gateway, plugin_mock):
plugin_mock.health_check = AsyncMock(side_effect=RuntimeError("boom"))
result = await gateway.health_check("feishu", "acc-1")
assert result.healthy is False
assert result.state == "error"
async def test_health_check_success(gateway, plugin_mock):
plugin_mock.health_check = AsyncMock(return_value=MagicMock(healthy=True))
result = await gateway.health_check("feishu", "acc-1")
assert result.healthy is True
class AsyncIterator:
"""Wrap a list for use in `async for`."""
def __init__(self, items, raise_on_exhaustion: type[BaseException] | None = None):
self._items = list(items)
self._raise_on_exhaustion = raise_on_exhaustion
def __aiter__(self):
return self
async def __anext__(self):
if not self._items:
if self._raise_on_exhaustion is not None:
raise self._raise_on_exhaustion()
raise StopAsyncIteration
return self._items.pop(0)