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)