590 lines
22 KiB
Python
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)
|