ForcePilot/backend/test/unit/channel/routing/test_router.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

521 lines
18 KiB
Python

from __future__ import annotations
import hashlib
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from yuxi.channel.plugins.protocol import (
BindingConversationRef,
BindingRoute,
InboundMessage,
SessionConversationRef,
)
from yuxi.channel.routing.router import BindingRouter, _BindingContext
@pytest.fixture
def plugin():
mock = MagicMock()
mock.compile_binding = MagicMock(return_value=None)
mock.match_binding = MagicMock(return_value=False)
return mock
@pytest.fixture
def route_cache():
return MagicMock()
@pytest.fixture
def router(route_cache):
return BindingRouter(route_cache=route_cache)
@pytest.fixture
def inbound():
return InboundMessage(
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
session_key="feishu:acc1:dm:user1",
sender_id="user1",
)
@pytest.fixture
def session_ref():
return SessionConversationRef(
session_key="feishu:acc1:dm:user1",
chat_type="dm",
channel_sender_id="user1",
)
class TestBindingRouterHelpers:
def test_build_cache_key(self, router):
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
assert router._build_cache_key(ctx) == "feishu:acc1:feishu:acc1:dm:user1"
def test_compute_rule_hash_is_stable(self, router):
rule = {"agent_id": "a1", "channel_type": "feishu"}
payload = json.dumps(rule, sort_keys=True, ensure_ascii=False, default=str)
expected = hashlib.sha256(payload.encode("utf-8")).hexdigest()
assert router._compute_rule_hash(rule) == expected
def test_parse_session_key_full(self, router):
assert router._parse_session_key("feishu:acc1:dm:user1") == ("feishu", "acc1")
def test_parse_session_key_empty(self, router):
assert router._parse_session_key("") == (None, None)
def test_parse_session_key_single_part(self, router):
assert router._parse_session_key("feishu") == ("feishu", None)
def test_parse_session_key_two_parts(self, router):
assert router._parse_session_key("feishu:acc1") == ("feishu", "acc1")
def test_parse_session_key_many_parts(self, router):
assert router._parse_session_key("feishu:acc1:dm:user1:thread") == (
"feishu",
"acc1",
)
def test_make_context_from_config(self, router, plugin, session_ref):
config = {"channel_type": "feishu", "account_id": "acc1"}
ctx = router._make_context(config, plugin, session_ref)
assert ctx.channel_type == "feishu"
assert ctx.account_id == "acc1"
assert ctx.session_key == "feishu:acc1:dm:user1"
def test_make_context_from_session_key(self, router, plugin, session_ref):
config = {}
ctx = router._make_context(config, plugin, session_ref)
assert ctx.channel_type == "feishu"
assert ctx.account_id == "acc1"
def test_make_context_raises_when_unresolvable(self, router, plugin):
ref = SessionConversationRef(session_key="single")
with pytest.raises(ValueError, match="Unable to resolve"):
router._make_context({}, plugin, ref)
class TestResolveConfiguredBinding:
async def test_plugin_binding_takes_precedence(self, router, plugin, inbound):
plugin.compile_binding.return_value = BindingConversationRef(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
)
plugin.match_binding.return_value = True
config = {
"bindings": [
{"agent_id": "plugin_agent", "custom": True},
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.agent_id == "plugin_agent"
assert route.matched_by == "binding.plugin"
plugin.compile_binding.assert_called_once_with(config, inbound)
plugin.match_binding.assert_called_once_with(config, config["bindings"][0], inbound)
async def test_plugin_binding_not_used_when_compile_returns_none(self, router, plugin, inbound):
plugin.compile_binding.return_value = None
config = {
"bindings": [
{
"agent_id": "tier_agent",
"channel_type": "feishu",
"account_id": "acc1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.agent_id == "tier_agent"
assert route.matched_by == "binding.channel_account"
plugin.match_binding.assert_not_called()
async def test_no_bindings_returns_none(self, router, plugin, inbound):
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, {}, plugin, inbound)
assert route is None
async def test_session_key_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"session_key": "feishu:acc1:dm:user1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.agent_id == "agent1"
assert route.matched_by == "binding.session_key"
async def test_channel_account_chat_peer_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
"account_id": "acc1",
"chat_type": "dm",
"peer_id": "user1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.channel_account_chat_peer"
async def test_channel_account_peer_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
"account_id": "acc1",
"peer_id": "user1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.channel_account_peer"
async def test_channel_account_chat_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
"account_id": "acc1",
"chat_type": "dm",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.channel_account_chat"
async def test_channel_chat_peer_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
"chat_type": "dm",
"peer_id": "user1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.channel_chat_peer"
async def test_session_key_regex_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"session_key_regex": "feishu:acc1:.*",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.session_key_regex"
async def test_channel_account_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
"account_id": "acc1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.channel_account"
async def test_channel_type_tier_matches(self, router, plugin, inbound):
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound)
assert route.matched_by == "binding.channel_type"
async def test_peer_id_falls_back_to_context(self, router, plugin):
inbound_no_peer = InboundMessage(
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id=None,
session_key="feishu:acc1:dm:user1",
)
config = {
"bindings": [
{
"agent_id": "agent1",
"channel_type": "feishu",
"account_id": "acc1",
"peer_id": "user1",
}
]
}
ctx = _BindingContext(
session_key="feishu:acc1:dm:user1",
channel_type="feishu",
account_id="acc1",
chat_type="dm",
peer_id="user1",
)
route = await router._resolve_configured_binding(ctx, config, plugin, inbound_no_peer)
assert route.matched_by == "binding.channel_account_peer"
class TestResolveRuntime:
async def test_cache_hit_returns_cached_route(self, router, route_cache, plugin, session_ref):
cached = BindingRoute(agent_id="cached", session_key="k", matched_by="cache")
route_cache.get = AsyncMock(return_value=cached)
route_cache.set = AsyncMock()
route = await router.resolve_runtime(
{"channel_type": "feishu", "account_id": "acc1"},
plugin,
session_ref,
MagicMock(),
)
assert route is cached
route_cache.get.assert_awaited_once()
route_cache.set.assert_not_called()
async def test_configured_binding_caches_and_returns(self, router, route_cache, plugin, session_ref):
route_cache.get = AsyncMock(return_value=None)
route_cache.set = AsyncMock()
plugin.compile_binding.return_value = None
config = {
"channel_type": "feishu",
"account_id": "acc1",
"bindings": [{"agent_id": "bound", "channel_type": "feishu", "account_id": "acc1"}],
}
route = await router.resolve_runtime(config, plugin, session_ref, MagicMock())
assert route.agent_id == "bound"
assert route.matched_by == "binding.channel_account"
route_cache.set.assert_awaited_once()
async def test_runtime_binding_caches_and_returns(self, router, route_cache, plugin, session_ref):
route_cache.get = AsyncMock(return_value=None)
route_cache.set = AsyncMock()
plugin.compile_binding.return_value = None
binding = MagicMock()
binding.agent_id = "runtime_agent"
binding.binding_rule_hash = "hash1"
with patch("yuxi.channel.routing.router.ChannelBindingRepository") as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.find_runtime_bindings = AsyncMock(return_value=[binding])
mock_repo_cls.return_value = mock_repo
route = await router.resolve_runtime(
{"channel_type": "feishu", "account_id": "acc1"},
plugin,
session_ref,
MagicMock(),
)
assert route.agent_id == "runtime_agent"
assert route.matched_by == "runtime.binding"
route_cache.set.assert_awaited_once()
async def test_default_route_uses_config_default_agent(self, router, route_cache, plugin, session_ref):
route_cache.get = AsyncMock(return_value=None)
route_cache.set = AsyncMock()
plugin.compile_binding.return_value = None
config = {
"channel_type": "feishu",
"account_id": "acc1",
"default_agent_id": "default_agent",
}
with patch("yuxi.channel.routing.router.ChannelBindingRepository") as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.find_runtime_bindings = AsyncMock(return_value=[])
mock_repo_cls.return_value = mock_repo
route = await router.resolve_runtime(config, plugin, session_ref, MagicMock())
assert route.agent_id == "default_agent"
assert route.matched_by == "default"
async def test_default_route_without_agent_id_not_cached(self, router, route_cache, plugin, session_ref):
route_cache.get = AsyncMock(return_value=None)
route_cache.set = AsyncMock()
plugin.compile_binding.return_value = None
config = {"channel_type": "feishu", "account_id": "acc1"}
with patch("yuxi.channel.routing.router.ChannelBindingRepository") as mock_repo_cls:
mock_repo = MagicMock()
mock_repo.find_runtime_bindings = AsyncMock(return_value=[])
mock_repo_cls.return_value = mock_repo
route = await router.resolve_runtime(config, plugin, session_ref, MagicMock())
assert route.agent_id is None
route_cache.set.assert_not_called()
class TestResolve:
async def test_cache_hit_returns_cached_route(self, router, route_cache, plugin, inbound):
cached = BindingRoute(agent_id="cached", session_key="k", matched_by="cache")
route_cache.get = AsyncMock(return_value=cached)
route_cache.set = AsyncMock()
session = MagicMock()
session.session_key = "feishu:acc1:dm:user1"
session.channel_type = "feishu"
session.account_id = "acc1"
session.chat_type = "dm"
session.channel_sender_id = "user1"
session.conversation_id = 1
route = await router.resolve(session, {"default_agent_id": "x"}, plugin, inbound, MagicMock())
assert route is cached
route_cache.set.assert_not_called()
async def test_falls_back_to_conversation_agent_when_no_default(self, router, route_cache, plugin, inbound):
route_cache.get = AsyncMock(return_value=None)
route_cache.set = AsyncMock()
plugin.compile_binding.return_value = None
conversation = MagicMock()
conversation.agent_id = "conv_agent"
with (
patch("yuxi.channel.routing.router.ConversationRepository") as mock_conv_repo_cls,
patch("yuxi.channel.routing.router.ChannelBindingRepository") as mock_binding_repo_cls,
):
mock_conv_repo = MagicMock()
mock_conv_repo.get_conversation_by_id = AsyncMock(return_value=conversation)
mock_conv_repo_cls.return_value = mock_conv_repo
mock_binding_repo = MagicMock()
mock_binding_repo.find_runtime_bindings = AsyncMock(return_value=[])
mock_binding_repo_cls.return_value = mock_binding_repo
session = MagicMock()
session.session_key = "feishu:acc1:dm:user1"
session.channel_type = "feishu"
session.account_id = "acc1"
session.chat_type = "dm"
session.channel_sender_id = "user1"
session.conversation_id = 1
route = await router.resolve(session, {}, plugin, inbound, MagicMock())
assert route.agent_id == "conv_agent"
assert route.matched_by == "default"
class TestInvalidate:
async def test_invalidate_channel_cache_delegates_to_cache(self, router, route_cache):
route_cache.invalidate_by_channel = AsyncMock(return_value=3)
assert await router.invalidate_channel_cache("feishu") == 3
async def test_invalidate_account_cache_delegates_to_cache(self, router, route_cache):
route_cache.invalidate_by_account = AsyncMock(return_value=2)
assert await router.invalidate_account_cache("feishu", "acc1") == 2