test: 新增渠道模块全链路单元测试用例与目录结构
完成渠道模块的单元测试目录搭建,新增多个领域模型、端口、中间件、事件、服务以及基础设施层的单元测试文件,同时补充了conftest.py的环境变量配置,完善测试基础环境。
This commit is contained in:
parent
9a8a27bf36
commit
6c95dc006a
@ -12,6 +12,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
# Avoid package-level knowledge graph initialization during pytest collection.
|
||||
os.environ.setdefault("YUXI_SKIP_APP_INIT", "1")
|
||||
os.environ.setdefault("OPENAI_API_KEY", "sk-test-fake")
|
||||
|
||||
|
||||
def pytest_configure(config: pytest.Config) -> None:
|
||||
|
||||
1
backend/test/integration/api/channel/__init__.py
Normal file
1
backend/test/integration/api/channel/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/__init__.py
Normal file
1
backend/test/unit/channel/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/application/__init__.py
Normal file
1
backend/test/unit/channel/application/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/application/dto/__init__.py
Normal file
1
backend/test/unit/channel/application/dto/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.application.dto.channel_dto import (
|
||||
BindingCreateRequest,
|
||||
ConfigReloadResult,
|
||||
DispatchPayload,
|
||||
InboundRequest,
|
||||
)
|
||||
|
||||
|
||||
def test_inbound_request_defaults() -> None:
|
||||
req = InboundRequest(channel_type="web")
|
||||
assert req.channel_type == "web"
|
||||
assert req.raw_payload == {}
|
||||
assert req.authorization == ""
|
||||
assert req.client_ip == ""
|
||||
assert req.trace_id == ""
|
||||
|
||||
|
||||
def test_inbound_request_with_values() -> None:
|
||||
req = InboundRequest(
|
||||
channel_type="feishu",
|
||||
raw_payload={"key": "value"},
|
||||
authorization="Bearer token",
|
||||
client_ip="127.0.0.1",
|
||||
trace_id="abc-123",
|
||||
)
|
||||
assert req.channel_type == "feishu"
|
||||
assert req.raw_payload == {"key": "value"}
|
||||
assert req.authorization == "Bearer token"
|
||||
assert req.client_ip == "127.0.0.1"
|
||||
assert req.trace_id == "abc-123"
|
||||
|
||||
|
||||
def test_dispatch_payload_defaults() -> None:
|
||||
payload = DispatchPayload(
|
||||
message_id="msg-1",
|
||||
channel_type="dingtalk",
|
||||
content="hello",
|
||||
sender_id="user-1",
|
||||
)
|
||||
assert payload.message_id == "msg-1"
|
||||
assert payload.channel_type == "dingtalk"
|
||||
assert payload.content == "hello"
|
||||
assert payload.sender_id == "user-1"
|
||||
assert payload.session_id == ""
|
||||
assert payload.agent_config_id is None
|
||||
assert payload.trace_id == ""
|
||||
assert payload.metadata == {}
|
||||
assert payload.raw_payload == {}
|
||||
assert payload.attachments == []
|
||||
|
||||
|
||||
def test_dispatch_payload_with_values() -> None:
|
||||
payload = DispatchPayload(
|
||||
message_id="msg-2",
|
||||
channel_type="web",
|
||||
content="world",
|
||||
sender_id="user-2",
|
||||
session_id="sess-1",
|
||||
agent_config_id=42,
|
||||
trace_id="trace-1",
|
||||
metadata={"foo": "bar"},
|
||||
raw_payload={"original": "data"},
|
||||
attachments=[{"url": "http://example.com"}],
|
||||
)
|
||||
assert payload.session_id == "sess-1"
|
||||
assert payload.agent_config_id == 42
|
||||
assert payload.trace_id == "trace-1"
|
||||
assert payload.metadata == {"foo": "bar"}
|
||||
assert payload.raw_payload == {"original": "data"}
|
||||
assert payload.attachments == [{"url": "http://example.com"}]
|
||||
|
||||
|
||||
def test_binding_create_request() -> None:
|
||||
req = BindingCreateRequest(
|
||||
channel_type="feishu",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=7,
|
||||
)
|
||||
assert req.channel_type == "feishu"
|
||||
assert req.account_id == "acc-1"
|
||||
assert req.group_id == "grp-1"
|
||||
assert req.agent_config_id == 7
|
||||
|
||||
|
||||
def test_config_reload_result() -> None:
|
||||
result = ConfigReloadResult(updated=["auth_token"], config_hash="a1b2c3d4")
|
||||
assert result.updated == ["auth_token"]
|
||||
assert result.config_hash == "a1b2c3d4"
|
||||
|
||||
result_none = ConfigReloadResult(updated=[], config_hash=None)
|
||||
assert result_none.updated == []
|
||||
assert result_none.config_hash is None
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.access_policy_middleware import AccessPolicyMiddleware
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
def _ctx(
|
||||
*,
|
||||
channel_type: str = "web",
|
||||
sender_id: str = "user-1",
|
||||
is_group: bool = False,
|
||||
group_id: str = "",
|
||||
) -> MessageContext:
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id=sender_id, name="User"),
|
||||
content="hello",
|
||||
metadata={"is_group": is_group, "group_id": group_id},
|
||||
)
|
||||
return MessageContext(message=msg, channel_type=channel_type)
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_open_policy() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "open"}})
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_disabled_policy() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "disabled"}})
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "DM_DISABLED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_allowlist_allowed() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "allowlist", "allow_from": ["user-1"]}})
|
||||
ctx = _ctx(sender_id="user-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_allowlist_blocked() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "allowlist", "allow_from": ["user-2"]}})
|
||||
ctx = _ctx(sender_id="user-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "DM_NOT_ALLOWED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_allowlist_wildcard() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "allowlist", "allow_from": ["*"]}})
|
||||
ctx = _ctx(sender_id="anyone")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_pairing_fallback_to_allowlist() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "pairing", "allow_from": ["user-1"]}})
|
||||
ctx = _ctx(sender_id="user-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_pairing_fallback_blocked() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "pairing", "allow_from": ["user-2"]}})
|
||||
ctx = _ctx(sender_id="user-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "DM_NOT_ALLOWED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_open_policy() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"group_policy": "open"}})
|
||||
ctx = _ctx(is_group=True, group_id="grp-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_disabled_policy() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"group_policy": "disabled"}})
|
||||
ctx = _ctx(is_group=True, group_id="grp-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "GROUP_DISABLED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_allowlist_allowed() -> None:
|
||||
mw = AccessPolicyMiddleware(
|
||||
policies={"web": {"group_policy": "allowlist", "group_allowlist": ["grp-1"]}}
|
||||
)
|
||||
ctx = _ctx(is_group=True, group_id="grp-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_allowlist_blocked() -> None:
|
||||
mw = AccessPolicyMiddleware(
|
||||
policies={"web": {"group_policy": "allowlist", "group_allowlist": ["grp-2"]}}
|
||||
)
|
||||
ctx = _ctx(is_group=True, group_id="grp-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "GROUP_NOT_ALLOWED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_policy_for_channel() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={})
|
||||
ctx = _ctx(channel_type="web")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_aborted_skipped() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "disabled"}})
|
||||
ctx = _ctx()
|
||||
ctx.abort("previous", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "PREV"
|
||||
|
||||
|
||||
def test_update_policies() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "open"}})
|
||||
mw.update_policies({"web": {"dm_policy": "disabled"}})
|
||||
assert mw._policies == {"web": {"dm_policy": "disabled"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={})
|
||||
result = await mw.on_config_updated({"access_policies": {"web": {"dm_policy": "open"}}})
|
||||
assert result == ["access_policy"]
|
||||
assert mw._policies == {"web": {"dm_policy": "open"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated_no_policies() -> None:
|
||||
mw = AccessPolicyMiddleware(policies={})
|
||||
result = await mw.on_config_updated({})
|
||||
assert result == []
|
||||
@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.auth_middleware import AuthMiddleware
|
||||
from yuxi.channel.application.service.auth_service import AuthService
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
class _FakeRateLimiter:
|
||||
async def check_and_incr(
|
||||
self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def is_locked(self, key: str) -> tuple[bool, int]:
|
||||
return False, 0
|
||||
|
||||
async def reset(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeAuthService:
|
||||
def __init__(self, passed: bool = True, reason: str = "") -> None:
|
||||
self._passed = passed
|
||||
self._reason = reason
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]:
|
||||
self.calls.append((auth_header, {"client_id": client_id}))
|
||||
return self._passed, self._reason
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(*, auth: str = "Bearer token", client_ip: str = "127.0.0.1") -> MessageContext:
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="User"),
|
||||
content="hello",
|
||||
raw_payload={"authorization": auth},
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_success() -> None:
|
||||
mw = AuthMiddleware(auth_service=_FakeAuthService(passed=True))
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_failure() -> None:
|
||||
mw = AuthMiddleware(auth_service=_FakeAuthService(passed=False, reason="bad token"))
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "AUTH_FAILED"
|
||||
assert "bad token" in result.abort_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_rate_limited() -> None:
|
||||
mw = AuthMiddleware(auth_service=_FakeAuthService(passed=False, reason="auth rate limited"))
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "AUTH_RATE_LIMITED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_uses_client_ip() -> None:
|
||||
auth = _FakeAuthService(passed=True)
|
||||
mw = AuthMiddleware(auth_service=auth)
|
||||
ctx = _ctx(client_ip="192.168.1.1")
|
||||
await mw.process(ctx, _call_next)
|
||||
assert auth.calls[0][1]["client_id"] == "192.168.1.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_empty_header() -> None:
|
||||
auth = _FakeAuthService(passed=False, reason="auth failed")
|
||||
mw = AuthMiddleware(auth_service=auth)
|
||||
ctx = _ctx(auth="")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert auth.calls[0][0] == ""
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = AuthMiddleware(auth_service=_FakeAuthService())
|
||||
assert mw.name == "auth"
|
||||
@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.dedup_middleware import DedupMiddleware
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
def __init__(self) -> None:
|
||||
self.data: dict[str, str] = {}
|
||||
self.ttls: dict[str, int] = {}
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
return self.data.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool = False) -> bool:
|
||||
if nx and key in self.data:
|
||||
return False
|
||||
self.data[key] = value
|
||||
if ex is not None:
|
||||
self.ttls[key] = ex
|
||||
return True
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self.data.pop(key, None)
|
||||
|
||||
async def incr(self, key: str) -> int:
|
||||
return 1
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> None:
|
||||
pass
|
||||
|
||||
async def ttl(self, key: str) -> int:
|
||||
return 0
|
||||
|
||||
async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple:
|
||||
return ()
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(*, idempotency_key: str | None = None, message_id: str = "msg-1") -> MessageContext:
|
||||
metadata: dict[str, Any] = {}
|
||||
raw_payload: dict[str, Any] = {}
|
||||
if idempotency_key:
|
||||
metadata["idempotency_key"] = idempotency_key
|
||||
msg = UnifiedMessage(
|
||||
message_id=message_id,
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="User"),
|
||||
content="hello",
|
||||
metadata=metadata,
|
||||
raw_payload=raw_payload,
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_request_passes() -> None:
|
||||
cache = _FakeCache()
|
||||
mw = DedupMiddleware(cache)
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_request_skipped() -> None:
|
||||
cache = _FakeCache()
|
||||
mw = DedupMiddleware(cache)
|
||||
ctx = _ctx(message_id="msg-2")
|
||||
await mw.process(ctx, _call_next)
|
||||
ctx2 = _ctx(message_id="msg-2")
|
||||
result = await mw.process(ctx2, _call_next)
|
||||
assert result.is_skipped is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_key_used() -> None:
|
||||
cache = _FakeCache()
|
||||
mw = DedupMiddleware(cache)
|
||||
ctx = _ctx(idempotency_key="idem-1")
|
||||
await mw.process(ctx, _call_next)
|
||||
ctx2 = _ctx(idempotency_key="idem-1")
|
||||
result = await mw.process(ctx2, _call_next)
|
||||
assert result.is_skipped is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_key_uses_custom_ttl() -> None:
|
||||
cache = _FakeCache()
|
||||
mw = DedupMiddleware(cache, idempotency_ttl=7200)
|
||||
ctx = _ctx(idempotency_key="idem-2")
|
||||
await mw.process(ctx, _call_next)
|
||||
assert cache.ttls.get("channel:dedup:idem-2") == 7200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_id_uses_default_ttl() -> None:
|
||||
cache = _FakeCache()
|
||||
mw = DedupMiddleware(cache)
|
||||
ctx = _ctx(message_id="msg-3")
|
||||
await mw.process(ctx, _call_next)
|
||||
assert cache.ttls.get("channel:dedup:msg-3") == 900
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_aborted_skipped() -> None:
|
||||
cache = _FakeCache()
|
||||
mw = DedupMiddleware(cache)
|
||||
ctx = _ctx()
|
||||
ctx.abort("prev", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = DedupMiddleware(_FakeCache())
|
||||
assert mw.name == "dedup"
|
||||
@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.enqueue_mq_middleware import EnqueueMQMiddleware
|
||||
from yuxi.channel.domain.model.message.attachment import Attachment
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def __init__(self) -> None:
|
||||
self.enqueued: list[dict] = []
|
||||
|
||||
async def enqueue(self, payload: dict) -> str:
|
||||
self.enqueued.append(payload)
|
||||
return f"mq-{payload['message_id']}"
|
||||
|
||||
async def dequeue(self, *, count: int = 1, block: int = 5000, consumer_name: str = "") -> list[dict]:
|
||||
return []
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
pass
|
||||
|
||||
async def pending_count(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(
|
||||
*,
|
||||
message_id: str = "msg-1",
|
||||
content: str = "hello",
|
||||
attachments: list[Attachment] | None = None,
|
||||
metadata: dict | None = None,
|
||||
raw_payload: dict | None = None,
|
||||
) -> MessageContext:
|
||||
msg = UnifiedMessage(
|
||||
message_id=message_id,
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="User"),
|
||||
content=content,
|
||||
session_id="sess-1",
|
||||
agent_config_id=42,
|
||||
metadata=metadata or {},
|
||||
raw_payload=raw_payload or {},
|
||||
attachments=attachments or [],
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web", trace_id="trace-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_success() -> None:
|
||||
queue = _FakeQueue()
|
||||
mw = EnqueueMQMiddleware(queue)
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
assert len(queue.enqueued) == 1
|
||||
assert queue.enqueued[0]["message_id"] == "msg-1"
|
||||
assert queue.enqueued[0]["content"] == "hello"
|
||||
assert queue.enqueued[0]["sender_id"] == "user-1"
|
||||
assert queue.enqueued[0]["session_id"] == "sess-1"
|
||||
assert queue.enqueued[0]["agent_config_id"] == 42
|
||||
assert result.message.metadata["_mq_message_id"] == "mq-msg-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_with_attachments() -> None:
|
||||
queue = _FakeQueue()
|
||||
mw = EnqueueMQMiddleware(queue)
|
||||
attachments = [
|
||||
Attachment(url="http://example.com/a.png", media_type="image", filename="a.png", size_bytes=1024, mime_type="image/png")
|
||||
]
|
||||
ctx = _ctx(attachments=attachments)
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert len(queue.enqueued[0]["attachments"]) == 1
|
||||
att = queue.enqueued[0]["attachments"][0]
|
||||
assert att["url"] == "http://example.com/a.png"
|
||||
assert att["media_type"] == "image"
|
||||
assert att["filename"] == "a.png"
|
||||
assert att["size_bytes"] == 1024
|
||||
assert att["mime_type"] == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_aborted_skipped() -> None:
|
||||
queue = _FakeQueue()
|
||||
mw = EnqueueMQMiddleware(queue)
|
||||
ctx = _ctx()
|
||||
ctx.abort("prev", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert len(queue.enqueued) == 0
|
||||
assert result.is_aborted is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_skipped_skipped() -> None:
|
||||
queue = _FakeQueue()
|
||||
mw = EnqueueMQMiddleware(queue)
|
||||
ctx = _ctx()
|
||||
ctx.skip()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert len(queue.enqueued) == 0
|
||||
assert result.is_skipped is True
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = EnqueueMQMiddleware(_FakeQueue())
|
||||
assert mw.name == "enqueue_mq"
|
||||
@ -0,0 +1,103 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.keyword_filter_middleware import KeywordFilterMiddleware
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
class _FakeKeywordMatcher:
|
||||
def __init__(self, match_result: str | None = None) -> None:
|
||||
self._match_result = match_result
|
||||
self.keywords: set[str] = set()
|
||||
|
||||
def match(self, content: str) -> str | None:
|
||||
return self._match_result
|
||||
|
||||
def update_keywords(self, keywords: set[str]) -> None:
|
||||
self.keywords = keywords
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(content: str = "hello") -> MessageContext:
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="User"),
|
||||
content=content,
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_match_passes() -> None:
|
||||
mw = KeywordFilterMiddleware(_FakeKeywordMatcher(match_result=None))
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_match_aborts() -> None:
|
||||
mw = KeywordFilterMiddleware(_FakeKeywordMatcher(match_result="badword"))
|
||||
ctx = _ctx(content="this has badword")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "KEYWORD_BLOCKED"
|
||||
assert "badword" in result.abort_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_aborted_skipped() -> None:
|
||||
mw = KeywordFilterMiddleware(_FakeKeywordMatcher(match_result="badword"))
|
||||
ctx = _ctx()
|
||||
ctx.abort("prev", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "PREV"
|
||||
|
||||
|
||||
def test_update_keywords() -> None:
|
||||
matcher = _FakeKeywordMatcher()
|
||||
mw = KeywordFilterMiddleware(matcher)
|
||||
mw.update_keywords({"foo", "bar"})
|
||||
assert matcher.keywords == {"foo", "bar"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated_with_blocklist() -> None:
|
||||
matcher = _FakeKeywordMatcher()
|
||||
mw = KeywordFilterMiddleware(matcher)
|
||||
result = await mw.on_config_updated({"keyword_blocklist": ["spam", "bad"]})
|
||||
assert result == ["keyword_filter"]
|
||||
assert matcher.keywords == {"spam", "bad"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated_with_set() -> None:
|
||||
matcher = _FakeKeywordMatcher()
|
||||
mw = KeywordFilterMiddleware(matcher)
|
||||
result = await mw.on_config_updated({"keyword_blocklist": {"spam", "bad"}})
|
||||
assert result == ["keyword_filter"]
|
||||
assert matcher.keywords == {"spam", "bad"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated_no_blocklist() -> None:
|
||||
matcher = _FakeKeywordMatcher()
|
||||
mw = KeywordFilterMiddleware(matcher)
|
||||
result = await mw.on_config_updated({})
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = KeywordFilterMiddleware(_FakeKeywordMatcher())
|
||||
assert mw.name == "keyword_filter"
|
||||
@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(*, is_group: bool = False, content: str = "hello", require_mention: bool | None = None) -> MessageContext:
|
||||
metadata: dict[str, Any] = {"is_group": is_group}
|
||||
if require_mention is not None:
|
||||
metadata["require_mention"] = require_mention
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="User"),
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_always_passes() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
ctx = _ctx(is_group=False)
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_no_mention_required() -> None:
|
||||
mw = MentionGateMiddleware(default_require_mention=False)
|
||||
ctx = _ctx(is_group=True)
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_mention_required_not_met() -> None:
|
||||
mw = MentionGateMiddleware(default_require_mention=True)
|
||||
ctx = _ctx(is_group=True, content="hello")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_mention_by_bot_id() -> None:
|
||||
mw = MentionGateMiddleware(default_require_mention=True)
|
||||
mw.set_bot_id("bot-123")
|
||||
ctx = _ctx(is_group=True, content="hello @bot-123")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_mention_by_pattern() -> None:
|
||||
mw = MentionGateMiddleware(default_require_mention=True, mention_patterns=[r"@assistant"])
|
||||
ctx = _ctx(is_group=True, content="hello @assistant")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_mention_by_pattern_not_matched() -> None:
|
||||
mw = MentionGateMiddleware(default_require_mention=True, mention_patterns=[r"@assistant"])
|
||||
ctx = _ctx(is_group=True, content="hello everyone")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_group_require_mention_overridden_false() -> None:
|
||||
mw = MentionGateMiddleware(default_require_mention=True)
|
||||
ctx = _ctx(is_group=True, require_mention=False)
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_skipped is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_aborted_skipped() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
ctx = _ctx()
|
||||
ctx.abort("prev", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
|
||||
|
||||
def test_set_bot_id() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
mw.set_bot_id("bot-456")
|
||||
assert mw._bot_id == "bot-456"
|
||||
|
||||
|
||||
def test_update_patterns() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
mw.update_patterns(require_mention=False, patterns=[r"@bot"])
|
||||
assert mw._default_require_mention is False
|
||||
assert len(mw._mention_patterns) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
result = await mw.on_config_updated(
|
||||
{"mention_gate_config": {"default_require_mention": False, "mention_patterns": ["@bot"]}}
|
||||
)
|
||||
assert result == ["mention_gate"]
|
||||
assert mw._default_require_mention is False
|
||||
assert len(mw._mention_patterns) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_config_updated_empty() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
result = await mw.on_config_updated({})
|
||||
assert result == []
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = MentionGateMiddleware()
|
||||
assert mw.name == "mention_gate"
|
||||
@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.rate_limit_middleware import RateLimitMiddleware
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
class _FakeRateLimiter:
|
||||
def __init__(self, allow: bool = True) -> None:
|
||||
self._allow = allow
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def check_and_incr(
|
||||
self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0
|
||||
) -> bool:
|
||||
self.calls.append(
|
||||
{"key": key, "max_attempts": max_attempts, "window_seconds": window_seconds}
|
||||
)
|
||||
return self._allow
|
||||
|
||||
async def is_locked(self, key: str) -> tuple[bool, int]:
|
||||
return False, 0
|
||||
|
||||
async def reset(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(*, sender_id: str = "user-1", client_ip: str = "127.0.0.1") -> MessageContext:
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id=sender_id, name="User"),
|
||||
content="hello",
|
||||
metadata={"client_ip": client_ip},
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_allowed() -> None:
|
||||
limiter = _FakeRateLimiter(allow=True)
|
||||
mw = RateLimitMiddleware(limiter, max_requests=10, window_seconds=60)
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
assert limiter.calls[0]["max_attempts"] == 10
|
||||
assert limiter.calls[0]["window_seconds"] == 60
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_blocked() -> None:
|
||||
limiter = _FakeRateLimiter(allow=False)
|
||||
mw = RateLimitMiddleware(limiter)
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "RATE_LIMITED"
|
||||
assert "rate limit exceeded" in result.abort_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_uses_client_ip() -> None:
|
||||
limiter = _FakeRateLimiter(allow=True)
|
||||
mw = RateLimitMiddleware(limiter)
|
||||
ctx = _ctx(client_ip="192.168.1.1")
|
||||
await mw.process(ctx, _call_next)
|
||||
assert "192.168.1.1" in limiter.calls[0]["key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_uses_sender_id_when_no_ip() -> None:
|
||||
limiter = _FakeRateLimiter(allow=True)
|
||||
mw = RateLimitMiddleware(limiter)
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-42", name="User"),
|
||||
content="hello",
|
||||
metadata={},
|
||||
)
|
||||
ctx = MessageContext(message=msg, channel_type="web")
|
||||
await mw.process(ctx, _call_next)
|
||||
assert "user-42" in limiter.calls[0]["key"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_aborted_skipped() -> None:
|
||||
limiter = _FakeRateLimiter(allow=False)
|
||||
mw = RateLimitMiddleware(limiter)
|
||||
ctx = _ctx()
|
||||
ctx.abort("prev", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert len(limiter.calls) == 0
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = RateLimitMiddleware(_FakeRateLimiter())
|
||||
assert mw.name == "rate_limit"
|
||||
@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.middlewares.validation_middleware import ValidationMiddleware
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
class _FakeSignatureVerifier:
|
||||
def __init__(self, valid: bool = True) -> None:
|
||||
self._valid = valid
|
||||
self.calls: list[tuple[dict, str, str]] = []
|
||||
|
||||
async def verify(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
self.calls.append((payload, signature, timestamp))
|
||||
return self._valid
|
||||
|
||||
|
||||
async def _call_next(ctx: MessageContext) -> MessageContext:
|
||||
return ctx
|
||||
|
||||
|
||||
def _ctx(
|
||||
*,
|
||||
message_id: str = "msg-1",
|
||||
content: str = "hello",
|
||||
sender_id: str = "user-1",
|
||||
signature: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
raw_payload: dict | None = None,
|
||||
) -> MessageContext:
|
||||
metadata: dict[str, Any] = {}
|
||||
rp: dict[str, Any] = raw_payload or {}
|
||||
if signature:
|
||||
rp["signature"] = signature
|
||||
if timestamp:
|
||||
rp["timestamp"] = timestamp
|
||||
msg = UnifiedMessage(
|
||||
message_id=message_id,
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id=sender_id, name="User"),
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
raw_payload=rp,
|
||||
)
|
||||
return MessageContext(message=msg, channel_type="web")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_message_passes() -> None:
|
||||
mw = ValidationMiddleware()
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_message_id() -> None:
|
||||
mw = ValidationMiddleware()
|
||||
ctx = _ctx(message_id="")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "VALIDATION_ERROR"
|
||||
assert "missing message_id" in result.abort_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_content() -> None:
|
||||
mw = ValidationMiddleware()
|
||||
ctx = _ctx(content=" ")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "VALIDATION_ERROR"
|
||||
assert "empty content" in result.abort_reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_payload_too_large() -> None:
|
||||
mw = ValidationMiddleware(max_body_bytes=10)
|
||||
ctx = _ctx(content="this is way too large for the limit")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "PAYLOAD_TOO_LARGE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_not_allowed() -> None:
|
||||
mw = ValidationMiddleware(allow_from={"web": ["user-2"]})
|
||||
ctx = _ctx(sender_id="user-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "SENDER_NOT_ALLOWED"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_allowed() -> None:
|
||||
mw = ValidationMiddleware(allow_from={"web": ["user-1"]})
|
||||
ctx = _ctx(sender_id="user-1")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_wildcard_allowed() -> None:
|
||||
mw = ValidationMiddleware(allow_from={"web": ["*"]})
|
||||
ctx = _ctx(sender_id="anyone")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_allow_from_for_channel() -> None:
|
||||
mw = ValidationMiddleware(allow_from={"feishu": ["user-1"]})
|
||||
ctx = _ctx(sender_id="user-2")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signature_verification_success() -> None:
|
||||
verifier = _FakeSignatureVerifier(valid=True)
|
||||
mw = ValidationMiddleware(signature_verifier=verifier)
|
||||
ctx = _ctx(signature="sig-1", timestamp="1234567890")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
assert len(verifier.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_signature_verification_failure() -> None:
|
||||
verifier = _FakeSignatureVerifier(valid=False)
|
||||
mw = ValidationMiddleware(signature_verifier=verifier)
|
||||
ctx = _ctx(signature="sig-1", timestamp="1234567890")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "SIGNATURE_ERROR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_signature_skips_verification() -> None:
|
||||
verifier = _FakeSignatureVerifier(valid=True)
|
||||
mw = ValidationMiddleware(signature_verifier=verifier)
|
||||
ctx = _ctx()
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is False
|
||||
assert len(verifier.calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_aborted_skipped() -> None:
|
||||
mw = ValidationMiddleware()
|
||||
ctx = _ctx()
|
||||
ctx.abort("prev", "PREV")
|
||||
result = await mw.process(ctx, _call_next)
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_code == "PREV"
|
||||
|
||||
|
||||
def test_update_allow_from() -> None:
|
||||
mw = ValidationMiddleware(allow_from={"web": ["user-1"]})
|
||||
mw.update_allow_from({"web": ["user-2"]})
|
||||
assert mw._allow_from == {"web": ["user-2"]}
|
||||
|
||||
|
||||
def test_name_property() -> None:
|
||||
mw = ValidationMiddleware()
|
||||
assert mw.name == "validation"
|
||||
179
backend/test/unit/channel/application/pipeline/test_builder.py
Normal file
179
backend/test/unit/channel/application/pipeline/test_builder.py
Normal file
@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.pipeline.builder import build_inbound_pipeline
|
||||
from yuxi.channel.application.service.auth_service import AuthService
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
|
||||
|
||||
class _FakeRateLimiter:
|
||||
async def check_and_incr(
|
||||
self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def is_locked(self, key: str) -> tuple[bool, int]:
|
||||
return False, 0
|
||||
|
||||
async def reset(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
async def get(self, key: str) -> str | None:
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
async def incr(self, key: str) -> int:
|
||||
return 1
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> None:
|
||||
pass
|
||||
|
||||
async def ttl(self, key: str) -> int:
|
||||
return 0
|
||||
|
||||
async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple:
|
||||
return ()
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
async def enqueue(self, payload: dict) -> str:
|
||||
return "msg-id"
|
||||
|
||||
async def dequeue(self, *, count: int = 1, block: int = 5000, consumer_name: str = "") -> list[dict]:
|
||||
return []
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
pass
|
||||
|
||||
async def pending_count(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class _FakeKeywordMatcher:
|
||||
def match(self, content: str) -> str | None:
|
||||
return None
|
||||
|
||||
def update_keywords(self, keywords: set[str]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeSignatureVerifier:
|
||||
async def verify(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
class _FakeMetrics:
|
||||
async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_total(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def set_outbox_pending(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_outbox_enqueued(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_sse_connections(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_auth_attempts(self, result: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_mention_gate_skipped(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_hooks_received(self, match_path: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_content_filter_blocked(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_service() -> AuthService:
|
||||
return AuthService(rate_limit_port=_FakeRateLimiter(), token="token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_kwargs(auth_service: AuthService) -> dict:
|
||||
return {
|
||||
"auth_service": auth_service,
|
||||
"cache_port": _FakeCache(),
|
||||
"rate_limit_port": _FakeRateLimiter(),
|
||||
"queue_port": _FakeQueue(),
|
||||
}
|
||||
|
||||
|
||||
def test_build_inbound_pipeline_returns_pipeline(base_kwargs: dict) -> None:
|
||||
pipeline = build_inbound_pipeline(**base_kwargs)
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
assert len(pipeline.middlewares) == 7
|
||||
|
||||
|
||||
def test_build_inbound_pipeline_with_bot_id(base_kwargs: dict) -> None:
|
||||
pipeline = build_inbound_pipeline(**base_kwargs, bot_id="bot-123")
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
|
||||
|
||||
def test_build_inbound_pipeline_with_custom_matcher(base_kwargs: dict) -> None:
|
||||
pipeline = build_inbound_pipeline(**base_kwargs, keyword_matcher=_FakeKeywordMatcher())
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
|
||||
|
||||
def test_build_inbound_pipeline_with_signature_verifier(base_kwargs: dict) -> None:
|
||||
pipeline = build_inbound_pipeline(**base_kwargs, signature_verifier=_FakeSignatureVerifier())
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
|
||||
|
||||
def test_build_inbound_pipeline_with_metrics(base_kwargs: dict) -> None:
|
||||
pipeline = build_inbound_pipeline(**base_kwargs, metrics=_FakeMetrics())
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
|
||||
|
||||
def test_build_inbound_pipeline_with_channel_config(base_kwargs: dict) -> None:
|
||||
config = {
|
||||
"keyword_blocklist": ["bad"],
|
||||
"allow_from": {"web": ["user-1"]},
|
||||
"access_policies": {"web": {"dm_policy": "disabled"}},
|
||||
}
|
||||
pipeline = build_inbound_pipeline(**base_kwargs, channel_config=config)
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,153 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.auth_service import AuthService, _safe_compare
|
||||
|
||||
|
||||
class _FakeRateLimiter:
|
||||
def __init__(self) -> None:
|
||||
self.locks: dict[str, tuple[bool, int]] = {}
|
||||
self.attempts: dict[str, dict[str, Any]] = {}
|
||||
self.resets: list[str] = []
|
||||
|
||||
async def is_locked(self, key: str) -> tuple[bool, int]:
|
||||
return self.locks.get(key, (False, 0))
|
||||
|
||||
async def check_and_incr(
|
||||
self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0
|
||||
) -> bool:
|
||||
self.attempts[key] = {
|
||||
"max_attempts": max_attempts,
|
||||
"window_seconds": window_seconds,
|
||||
"lockout_seconds": lockout_seconds,
|
||||
}
|
||||
return True
|
||||
|
||||
async def reset(self, key: str) -> None:
|
||||
self.resets.append(key)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_rate_limiter() -> _FakeRateLimiter:
|
||||
return _FakeRateLimiter()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_service(fake_rate_limiter: _FakeRateLimiter) -> AuthService:
|
||||
return AuthService(
|
||||
rate_limit_port=fake_rate_limiter,
|
||||
token="secret-token",
|
||||
password="secret-password",
|
||||
max_attempts=3,
|
||||
lockout_seconds=60,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_no_credentials_configured(fake_rate_limiter: _FakeRateLimiter) -> None:
|
||||
service = AuthService(rate_limit_port=fake_rate_limiter)
|
||||
passed, reason = await service.authenticate("")
|
||||
assert passed is True
|
||||
assert reason == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_bearer_token_success(auth_service: AuthService) -> None:
|
||||
passed, reason = await service.authenticate("Bearer secret-token")
|
||||
assert passed is True
|
||||
assert reason == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_bearer_token_failure(auth_service: AuthService) -> None:
|
||||
passed, reason = await service.authenticate("Bearer wrong-token")
|
||||
assert passed is False
|
||||
assert reason == "auth failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_basic_password_success(auth_service: AuthService) -> None:
|
||||
encoded = base64.b64encode(b"user:secret-password").decode()
|
||||
passed, reason = await service.authenticate(f"Basic {encoded}")
|
||||
assert passed is True
|
||||
assert reason == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_basic_password_failure(auth_service: AuthService) -> None:
|
||||
encoded = base64.b64encode(b"user:wrong-password").decode()
|
||||
passed, reason = await service.authenticate(f"Basic {encoded}")
|
||||
assert passed is False
|
||||
assert reason == "auth failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_invalid_basic_format(auth_service: AuthService) -> None:
|
||||
encoded = base64.b64encode(b"nocolon").decode()
|
||||
passed, reason = await service.authenticate(f"Basic {encoded}")
|
||||
assert passed is False
|
||||
assert reason == "auth failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_malformed_basic_header(auth_service: AuthService) -> None:
|
||||
passed, reason = await service.authenticate("Basic not-valid-base64!!!")
|
||||
assert passed is False
|
||||
assert reason == "auth failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_no_matching_scheme(auth_service: AuthService) -> None:
|
||||
passed, reason = await service.authenticate("Digest something")
|
||||
assert passed is False
|
||||
assert reason == "auth failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_rate_limited_lockout(
|
||||
fake_rate_limiter: _FakeRateLimiter, auth_service: AuthService
|
||||
) -> None:
|
||||
fake_rate_limiter.locks["channel:auth:lockout::127.0.0.1"] = (True, 120)
|
||||
passed, reason = await service.authenticate("Bearer wrong", client_id="127.0.0.1")
|
||||
assert passed is False
|
||||
assert "retry after 120s" in reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_success_resets_attempts(
|
||||
fake_rate_limiter: _FakeRateLimiter, auth_service: AuthService
|
||||
) -> None:
|
||||
await service.authenticate("Bearer secret-token", client_id="client-1")
|
||||
assert "channel:auth:attempts:client-1" in fake_rate_limiter.resets
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authenticate_failure_increments_attempts(
|
||||
fake_rate_limiter: _FakeRateLimiter, auth_service: AuthService
|
||||
) -> None:
|
||||
await service.authenticate("Bearer wrong", client_id="client-2")
|
||||
assert "channel:auth:attempts:client-2" in fake_rate_limiter.attempts
|
||||
record = fake_rate_limiter.attempts["channel:auth:attempts:client-2"]
|
||||
assert record["max_attempts"] == 3
|
||||
assert record["window_seconds"] == 60
|
||||
assert record["lockout_seconds"] == 60
|
||||
|
||||
|
||||
def test_update_credentials() -> None:
|
||||
service = AuthService(rate_limit_port=_FakeRateLimiter(), token="old")
|
||||
service.update_credentials(token="new", password="pwd")
|
||||
assert service._token == "new"
|
||||
assert service._password == "pwd"
|
||||
|
||||
|
||||
def test_safe_compare_equal() -> None:
|
||||
assert _safe_compare("hello", "hello") is True
|
||||
|
||||
|
||||
def test_safe_compare_not_equal() -> None:
|
||||
assert _safe_compare("hello", "world") is False
|
||||
@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.binding_service import BindingService
|
||||
from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
|
||||
|
||||
|
||||
class _FakeBindingRepo:
|
||||
def __init__(self) -> None:
|
||||
self.bindings: dict[int, ChannelBinding] = {}
|
||||
self.next_id = 1
|
||||
self.deleted: list[int] = []
|
||||
|
||||
async def find_binding(
|
||||
self, *, channel_type: str, account_id: str, group_id: str
|
||||
) -> ChannelBinding | None:
|
||||
for b in self.bindings.values():
|
||||
if b.channel_type == channel_type and b.account_id == account_id and b.group_id == group_id:
|
||||
return b
|
||||
return None
|
||||
|
||||
async def create_binding(
|
||||
self,
|
||||
*,
|
||||
channel_type: str,
|
||||
account_id: str,
|
||||
group_id: str,
|
||||
agent_config_id: int,
|
||||
created_by: str | None = None,
|
||||
) -> ChannelBinding:
|
||||
binding = ChannelBinding(
|
||||
id=self.next_id,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
group_id=group_id,
|
||||
agent_config_id=agent_config_id,
|
||||
created_by=created_by,
|
||||
)
|
||||
self.bindings[self.next_id] = binding
|
||||
self.next_id += 1
|
||||
return binding
|
||||
|
||||
async def update_binding(
|
||||
self,
|
||||
binding_id: int,
|
||||
*,
|
||||
agent_config_id: int | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
updated_by: str | None = None,
|
||||
) -> ChannelBinding | None:
|
||||
b = self.bindings.get(binding_id)
|
||||
if b is None:
|
||||
return None
|
||||
if agent_config_id is not None:
|
||||
b.agent_config_id = agent_config_id
|
||||
if is_enabled is not None:
|
||||
b.is_enabled = is_enabled
|
||||
return b
|
||||
|
||||
async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool:
|
||||
if binding_id in self.bindings:
|
||||
self.deleted.append(binding_id)
|
||||
del self.bindings[binding_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_binding(self, binding_id: int) -> ChannelBinding | None:
|
||||
return self.bindings.get(binding_id)
|
||||
|
||||
async def list_bindings(
|
||||
self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50
|
||||
) -> tuple[list[ChannelBinding], int]:
|
||||
items = list(self.bindings.values())
|
||||
if channel_type:
|
||||
items = [b for b in items if b.channel_type == channel_type]
|
||||
total = len(items)
|
||||
items = items[offset : offset + limit]
|
||||
return items, total
|
||||
|
||||
async def invalidate_cache(
|
||||
self, *, channel_type: str, account_id: str, group_id: str
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_repo() -> _FakeBindingRepo:
|
||||
return _FakeBindingRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def binding_service(fake_repo: _FakeBindingRepo) -> BindingService:
|
||||
return BindingService(binding_repo=fake_repo)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_binding(binding_service: BindingService) -> None:
|
||||
binding = await binding_service.create(
|
||||
channel_type="feishu",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=5,
|
||||
)
|
||||
assert binding.id == 1
|
||||
assert binding.channel_type == "feishu"
|
||||
assert binding.account_id == "acc-1"
|
||||
assert binding.group_id == "grp-1"
|
||||
assert binding.agent_config_id == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_binding_success(binding_service: BindingService, fake_repo: _FakeBindingRepo) -> None:
|
||||
created = await binding_service.create(
|
||||
channel_type="web", account_id="acc-2", group_id="grp-2", agent_config_id=10
|
||||
)
|
||||
result = await binding_service.delete(created.id)
|
||||
assert result is True
|
||||
assert created.id in fake_repo.deleted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_binding_not_found(binding_service: BindingService) -> None:
|
||||
result = await binding_service.delete(999)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_binding(binding_service: BindingService) -> None:
|
||||
created = await binding_service.create(
|
||||
channel_type="dingtalk", account_id="acc-3", group_id="grp-3", agent_config_id=15
|
||||
)
|
||||
fetched = await binding_service.get(created.id)
|
||||
assert fetched is not None
|
||||
assert fetched.id == created.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_binding_not_found(binding_service: BindingService) -> None:
|
||||
fetched = await binding_service.get(999)
|
||||
assert fetched is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_bindings(binding_service: BindingService) -> None:
|
||||
await binding_service.create(channel_type="feishu", account_id="a1", group_id="g1", agent_config_id=1)
|
||||
await binding_service.create(channel_type="feishu", account_id="a2", group_id="g2", agent_config_id=2)
|
||||
await binding_service.create(channel_type="web", account_id="a3", group_id="g3", agent_config_id=3)
|
||||
|
||||
items, total = await binding_service.list()
|
||||
assert total == 3
|
||||
assert len(items) == 3
|
||||
|
||||
items, total = await binding_service.list(channel_type="feishu")
|
||||
assert total == 2
|
||||
assert len(items) == 2
|
||||
assert all(b.channel_type == "feishu" for b in items)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_bindings_pagination(binding_service: BindingService) -> None:
|
||||
for i in range(5):
|
||||
await binding_service.create(
|
||||
channel_type="web", account_id=f"a{i}", group_id=f"g{i}", agent_config_id=i
|
||||
)
|
||||
items, total = await binding_service.list(offset=2, limit=2)
|
||||
assert total == 5
|
||||
assert len(items) == 2
|
||||
@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.config_service import ConfigService
|
||||
from yuxi.channel.domain.middleware.configurable import Configurable
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
from yuxi.channel.domain.service.pipeline_builder import PipelineBuilder
|
||||
|
||||
|
||||
class _FakeConfigReload:
|
||||
def __init__(self, data: dict | None = None) -> None:
|
||||
self._data = data or {"auth": {"token": "new-token"}}
|
||||
|
||||
async def reload(self) -> dict:
|
||||
return self._data
|
||||
|
||||
async def watch(self, callback: Any) -> None:
|
||||
pass
|
||||
|
||||
async def get_current(self) -> dict:
|
||||
return self._data
|
||||
|
||||
|
||||
class _FakeChannelConfig:
|
||||
def __init__(self) -> None:
|
||||
self.updated: list[str] = []
|
||||
|
||||
async def on_config_updated(self, config: dict) -> list[str]:
|
||||
self.updated = ["auth_token"]
|
||||
return self.updated
|
||||
|
||||
|
||||
class _FakeConfigurableMiddleware:
|
||||
def __init__(self, name: str, items: list[str] | None = None) -> None:
|
||||
self._name = name
|
||||
self._items = items or []
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def on_config_updated(self, config: dict) -> list[str]:
|
||||
return list(self._items)
|
||||
|
||||
async def process(self, ctx: Any, call_next: Any) -> Any:
|
||||
return await call_next(ctx)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_config_reload() -> _FakeConfigReload:
|
||||
return _FakeConfigReload()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_channel_config() -> _FakeChannelConfig:
|
||||
return _FakeChannelConfig()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_pipeline() -> Pipeline:
|
||||
return PipelineBuilder().build()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline_with_configurable() -> Pipeline:
|
||||
return (
|
||||
PipelineBuilder()
|
||||
.add(_FakeConfigurableMiddleware("mw1", ["item1"]))
|
||||
.add(_FakeConfigurableMiddleware("mw2", ["item2", "item3"]))
|
||||
.build()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_updates_config(
|
||||
fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline
|
||||
) -> None:
|
||||
service = ConfigService(
|
||||
config_data={"old": "data"},
|
||||
config_reload=fake_config_reload,
|
||||
pipeline=empty_pipeline,
|
||||
)
|
||||
updated, config_hash = await service.reload()
|
||||
assert service.config == {"auth": {"token": "new-token"}}
|
||||
assert isinstance(config_hash, str)
|
||||
assert len(config_hash) == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_with_channel_config(
|
||||
fake_config_reload: _FakeConfigReload,
|
||||
fake_channel_config: _FakeChannelConfig,
|
||||
empty_pipeline: Pipeline,
|
||||
) -> None:
|
||||
service = ConfigService(
|
||||
config_data={},
|
||||
config_reload=fake_config_reload,
|
||||
pipeline=empty_pipeline,
|
||||
channel_config=fake_channel_config,
|
||||
)
|
||||
updated, _ = await service.reload()
|
||||
assert "auth_token" in updated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_updates_configurable_middlewares(
|
||||
fake_config_reload: _FakeConfigReload,
|
||||
pipeline_with_configurable: Pipeline,
|
||||
) -> None:
|
||||
service = ConfigService(
|
||||
config_data={},
|
||||
config_reload=fake_config_reload,
|
||||
pipeline=pipeline_with_configurable,
|
||||
)
|
||||
updated, _ = await service.reload()
|
||||
assert "item1" in updated
|
||||
assert "item2" in updated
|
||||
assert "item3" in updated
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_no_configurable_middlewares(
|
||||
fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline
|
||||
) -> None:
|
||||
service = ConfigService(
|
||||
config_data={},
|
||||
config_reload=fake_config_reload,
|
||||
pipeline=empty_pipeline,
|
||||
)
|
||||
updated, _ = await service.reload()
|
||||
assert updated == []
|
||||
|
||||
|
||||
def test_config_property(fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline) -> None:
|
||||
service = ConfigService(
|
||||
config_data={"key": "value"},
|
||||
config_reload=fake_config_reload,
|
||||
pipeline=empty_pipeline,
|
||||
)
|
||||
assert service.config == {"key": "value"}
|
||||
@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.delivery_service import DeliveryService
|
||||
from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError
|
||||
from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, SendResult
|
||||
from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest
|
||||
from yuxi.channel.domain.model.session.channel_session import ChannelSession
|
||||
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
|
||||
|
||||
class _FakeAgentPort:
|
||||
def __init__(self, response: str = "fake response", raise_error: bool = False) -> None:
|
||||
self.response = response
|
||||
self.raise_error = raise_error
|
||||
self.calls: list[StreamChatRequest] = []
|
||||
|
||||
async def stream_chat(self, request: StreamChatRequest) -> str:
|
||||
self.calls.append(request)
|
||||
if self.raise_error:
|
||||
raise RuntimeError("agent error")
|
||||
return self.response
|
||||
|
||||
async def stream_chat_iter(self, request: StreamChatRequest) -> Any:
|
||||
yield self.response
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
channel_type: str = "web",
|
||||
send_success: bool = True,
|
||||
capabilities: ChannelCapabilities | None = None,
|
||||
) -> None:
|
||||
self._channel_type = channel_type
|
||||
self._send_success = send_success
|
||||
self._capabilities = capabilities or ChannelCapabilities(
|
||||
media=False, group=False, dm=True, streaming=False, typing=False
|
||||
)
|
||||
self.sent_messages: list[tuple[str, str, dict]] = []
|
||||
self.sent_media: list[tuple[str, str, str, dict]] = []
|
||||
self.typing_calls: list[str] = []
|
||||
|
||||
@property
|
||||
def capabilities(self) -> ChannelCapabilities:
|
||||
return self._capabilities
|
||||
|
||||
@property
|
||||
def channel_type(self) -> str:
|
||||
return self._channel_type
|
||||
|
||||
@property
|
||||
def ws_connection(self) -> Any:
|
||||
return None
|
||||
|
||||
async def open(self) -> None:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def receive_message(self, raw: dict) -> Any:
|
||||
pass
|
||||
|
||||
async def send_message(
|
||||
self, session_id: str, content: str, *, channel_type: str, metadata: dict
|
||||
) -> SendResult:
|
||||
self.sent_messages.append((session_id, content, metadata))
|
||||
if self._send_success:
|
||||
return SendResult(success=True)
|
||||
return SendResult(success=False, error="send failed")
|
||||
|
||||
async def send_typing(self, session_id: str) -> None:
|
||||
self.typing_calls.append(session_id)
|
||||
|
||||
async def send_media(
|
||||
self, session_id: str, *, url: str, media_type: str, metadata: dict
|
||||
) -> bool:
|
||||
self.sent_media.append((session_id, url, media_type, metadata))
|
||||
return True
|
||||
|
||||
async def is_healthy(self) -> bool:
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
class _FakeMessageRepo:
|
||||
def __init__(self) -> None:
|
||||
self.saved_assistant: list[tuple[str, str]] = []
|
||||
|
||||
async def save_user_message(
|
||||
self, *, thread_id: str, content: str, message_id: str | None = None, extra_metadata: dict | None = None
|
||||
) -> Any:
|
||||
pass
|
||||
|
||||
async def save_assistant_message(
|
||||
self, *, thread_id: str, content: str, extra_metadata: dict | None = None
|
||||
) -> Any:
|
||||
self.saved_assistant.append((thread_id, content))
|
||||
return Any
|
||||
|
||||
|
||||
class _FakeOutboxRepo:
|
||||
def __init__(self) -> None:
|
||||
self.enqueued: list[dict] = []
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
session_id: str,
|
||||
channel_type: str,
|
||||
content: str,
|
||||
trace_id: str | None = None,
|
||||
extra_metadata: dict | None = None,
|
||||
) -> Any:
|
||||
self.enqueued.append(
|
||||
{
|
||||
"message_id": message_id,
|
||||
"session_id": session_id,
|
||||
"channel_type": channel_type,
|
||||
"content": content,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
)
|
||||
return Any
|
||||
|
||||
async def fetch_pending(self, *, limit: int = 20) -> list:
|
||||
return []
|
||||
|
||||
async def get_by_id(self, entry_id: int) -> Any:
|
||||
return None
|
||||
|
||||
async def mark_retrying(self, entry_id: int, *, last_error: str | None = None) -> Any:
|
||||
return None
|
||||
|
||||
async def mark_sent(self, entry_id: int) -> bool:
|
||||
return True
|
||||
|
||||
async def mark_dead(self, entry_id: int, *, last_error: str) -> bool:
|
||||
return True
|
||||
|
||||
async def mark_failed(self, entry_id: int, *, last_error: str) -> bool:
|
||||
return True
|
||||
|
||||
async def list_outbox(
|
||||
self, *, channel_type: str | None = None, status: str | None = None, limit: int = 50
|
||||
) -> list:
|
||||
return []
|
||||
|
||||
|
||||
class _FakeEventPublisher:
|
||||
def __init__(self) -> None:
|
||||
self.published: list[Any] = []
|
||||
|
||||
async def publish(self, event: Any) -> None:
|
||||
self.published.append(event)
|
||||
|
||||
|
||||
class _FakeMessageLogRepo:
|
||||
def __init__(self) -> None:
|
||||
self.logs: list[dict] = []
|
||||
|
||||
async def create_log(self, **kwargs: Any) -> Any:
|
||||
self.logs.append(kwargs)
|
||||
return Any
|
||||
|
||||
async def update_worker_result(self, **kwargs: Any) -> bool:
|
||||
self.logs.append(kwargs)
|
||||
return True
|
||||
|
||||
async def update_pipeline_result(self, **kwargs: Any) -> bool:
|
||||
return True
|
||||
|
||||
async def get_by_trace_id(self, trace_id: str) -> list:
|
||||
return []
|
||||
|
||||
async def get_by_message_id(self, message_id: str) -> list:
|
||||
return []
|
||||
|
||||
async def query_logs(self, query: Any) -> list:
|
||||
return []
|
||||
|
||||
|
||||
class _FakeMetrics:
|
||||
def __init__(self) -> None:
|
||||
self.records: list[tuple[str, tuple]] = []
|
||||
|
||||
async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_total(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
self.records.append(("record_worker_dispatch_duration", (channel_type, duration_s)))
|
||||
|
||||
async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None:
|
||||
self.records.append(("record_worker_dispatch_total", (channel_type, result)))
|
||||
|
||||
async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def set_outbox_pending(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_outbox_enqueued(self, channel_type: str) -> None:
|
||||
self.records.append(("record_outbox_enqueued", (channel_type,)))
|
||||
|
||||
async def set_sse_connections(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_auth_attempts(self, result: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_mention_gate_skipped(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_hooks_received(self, match_path: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_content_filter_blocked(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session() -> ChannelSession:
|
||||
return ChannelSession(
|
||||
id=1,
|
||||
thread_id="thread-1",
|
||||
user_id="user-1",
|
||||
agent_id="1",
|
||||
channel_type="web",
|
||||
channel_session_key="key-1",
|
||||
status="active",
|
||||
title=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def payload() -> dict:
|
||||
return {
|
||||
"message_id": "msg-1",
|
||||
"channel_type": "web",
|
||||
"content": "hello",
|
||||
"sender_id": "user-1",
|
||||
"session_id": "thread-1",
|
||||
"trace_id": "trace-1",
|
||||
"metadata": {"deliver": True},
|
||||
"raw_payload": {},
|
||||
"attachments": [],
|
||||
"agent_config_id": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def delivery_service() -> DeliveryService:
|
||||
return DeliveryService(
|
||||
adapters={},
|
||||
message_repo=_FakeMessageRepo(),
|
||||
outbox_repo=_FakeOutboxRepo(),
|
||||
event_publisher=_FakeEventPublisher(),
|
||||
agent_port=_FakeAgentPort(),
|
||||
message_log_repo=_FakeMessageLogRepo(),
|
||||
metrics=_FakeMetrics(),
|
||||
agent_timeout=1.0,
|
||||
typing_interval=0.1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_success(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None:
|
||||
adapter = _FakeAdapter(channel_type="web", capabilities=ChannelCapabilities(typing=False, media=False))
|
||||
delivery_service._adapters = {"web": adapter}
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-1"
|
||||
assert len(adapter.sent_messages) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_no_adapter(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None:
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is False
|
||||
assert result.error == "no adapter for web"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_send_failure_enqueues_outbox(
|
||||
delivery_service: DeliveryService, payload: dict, session: ChannelSession
|
||||
) -> None:
|
||||
adapter = _FakeAdapter(channel_type="web", send_success=False)
|
||||
delivery_service._adapters = {"web": adapter}
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is True
|
||||
outbox = delivery_service._outbox
|
||||
assert isinstance(outbox, _FakeOutboxRepo)
|
||||
assert len(outbox.enqueued) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_skip_deliver(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None:
|
||||
payload["metadata"]["deliver"] = False
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_agent_crash(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None:
|
||||
delivery_service._agent = _FakeAgentPort(raise_error=True)
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is False
|
||||
assert result.error == "agent_crash"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_with_media_attachments(
|
||||
delivery_service: DeliveryService, payload: dict, session: ChannelSession
|
||||
) -> None:
|
||||
adapter = _FakeAdapter(
|
||||
channel_type="web",
|
||||
capabilities=ChannelCapabilities(media=True, typing=False),
|
||||
)
|
||||
delivery_service._adapters = {"web": adapter}
|
||||
payload["attachments"] = [{"url": "http://example.com/img.png", "media_type": "image"}]
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is True
|
||||
assert len(adapter.sent_media) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliver_with_typing_indicator(
|
||||
delivery_service: DeliveryService, payload: dict, session: ChannelSession
|
||||
) -> None:
|
||||
adapter = _FakeAdapter(
|
||||
channel_type="web",
|
||||
capabilities=ChannelCapabilities(typing=True),
|
||||
)
|
||||
delivery_service._adapters = {"web": adapter}
|
||||
delivery_service._typing_interval = 0.01
|
||||
result = await delivery_service.deliver(payload, session)
|
||||
assert result.success is True
|
||||
@ -0,0 +1,280 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.delivery_service import DeliveryService
|
||||
from yuxi.channel.application.service.dispatch_service import DispatchService
|
||||
from yuxi.channel.application.service.session_resolver import SessionResolver
|
||||
from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError
|
||||
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
|
||||
from yuxi.channel.domain.model.message.dispatch_result import DispatchResult
|
||||
from yuxi.channel.domain.model.session.channel_session import ChannelSession
|
||||
|
||||
|
||||
class _FakeContentFilter:
|
||||
def __init__(self, passed: bool = True, violations: list[str] | None = None, masked: list[str] | None = None) -> None:
|
||||
self._passed = passed
|
||||
self._violations = violations or []
|
||||
self._masked = masked or []
|
||||
|
||||
async def check(self, content: str, *, channel_type: str = "") -> Any:
|
||||
from yuxi.channel.domain.port.content_filter_port import FilterResult
|
||||
|
||||
return FilterResult(
|
||||
passed=self._passed,
|
||||
violations=self._violations,
|
||||
masked_fields=self._masked,
|
||||
filtered_content=content,
|
||||
)
|
||||
|
||||
|
||||
class _FakeBotLoopGuard:
|
||||
def __init__(self, allow: bool = True) -> None:
|
||||
self._allow = allow
|
||||
|
||||
async def check(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> bool:
|
||||
return self._allow
|
||||
|
||||
async def reset(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeCircuitBreaker:
|
||||
def __init__(self, available: bool = True) -> None:
|
||||
self._available = available
|
||||
self.successes: list[int] = []
|
||||
self.failures: list[int] = []
|
||||
|
||||
async def is_available(self, agent_config_id: int) -> bool:
|
||||
return self._available
|
||||
|
||||
async def record_success(self, agent_config_id: int) -> None:
|
||||
self.successes.append(agent_config_id)
|
||||
|
||||
async def record_failure(self, agent_config_id: int) -> None:
|
||||
self.failures.append(agent_config_id)
|
||||
|
||||
|
||||
class _FakeSessionResolver:
|
||||
def __init__(self, session: ChannelSession | None = None) -> None:
|
||||
self._session = session
|
||||
|
||||
async def resolve(self, payload: dict) -> ChannelSession | None:
|
||||
return self._session
|
||||
|
||||
|
||||
class _FakeDeliveryService:
|
||||
def __init__(self, result: DispatchResult | None = None, raise_error: Exception | None = None) -> None:
|
||||
self._result = result or DispatchResult(success=True, message_id="msg-1")
|
||||
self._raise = raise_error
|
||||
self.calls: list[tuple[dict, ChannelSession]] = []
|
||||
|
||||
async def deliver(self, payload: dict, session: ChannelSession) -> DispatchResult:
|
||||
self.calls.append((payload, session))
|
||||
if self._raise:
|
||||
raise self._raise
|
||||
return self._result
|
||||
|
||||
|
||||
class _FakeMessageLogRepo:
|
||||
def __init__(self) -> None:
|
||||
self.logs: list[dict] = []
|
||||
|
||||
async def create_log(self, **kwargs: Any) -> Any:
|
||||
self.logs.append(kwargs)
|
||||
return Any
|
||||
|
||||
async def update_worker_result(self, **kwargs: Any) -> bool:
|
||||
self.logs.append(kwargs)
|
||||
return True
|
||||
|
||||
async def update_pipeline_result(self, **kwargs: Any) -> bool:
|
||||
return True
|
||||
|
||||
async def get_by_trace_id(self, trace_id: str) -> list:
|
||||
return []
|
||||
|
||||
async def get_by_message_id(self, message_id: str) -> list:
|
||||
return []
|
||||
|
||||
async def query_logs(self, query: Any) -> list:
|
||||
return []
|
||||
|
||||
|
||||
class _FakeMetrics:
|
||||
def __init__(self) -> None:
|
||||
self.records: list[tuple[str, tuple]] = []
|
||||
|
||||
async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_total(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
self.records.append(("record_worker_dispatch_duration", (channel_type, duration_s)))
|
||||
|
||||
async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None:
|
||||
self.records.append(("record_worker_dispatch_total", (channel_type, result)))
|
||||
|
||||
async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def set_outbox_pending(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_outbox_enqueued(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_sse_connections(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_auth_attempts(self, result: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_mention_gate_skipped(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_hooks_received(self, match_path: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_content_filter_blocked(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session() -> ChannelSession:
|
||||
return ChannelSession(
|
||||
id=1,
|
||||
thread_id="thread-1",
|
||||
user_id="user-1",
|
||||
agent_id="1",
|
||||
channel_type="web",
|
||||
channel_session_key="key-1",
|
||||
status="active",
|
||||
title=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def payload() -> dict:
|
||||
return {
|
||||
"message_id": "msg-1",
|
||||
"channel_type": "web",
|
||||
"content": "hello",
|
||||
"sender_id": "user-1",
|
||||
"session_id": "thread-1",
|
||||
"trace_id": "trace-1",
|
||||
"metadata": {"is_group": False},
|
||||
"agent_config_id": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dispatch_service(session: ChannelSession) -> DispatchService:
|
||||
return DispatchService(
|
||||
content_filter=_FakeContentFilter(),
|
||||
bot_loop_guard=_FakeBotLoopGuard(),
|
||||
circuit_breaker=_FakeCircuitBreaker(),
|
||||
session_resolver=_FakeSessionResolver(session=session),
|
||||
delivery_service=_FakeDeliveryService(),
|
||||
message_log_repo=_FakeMessageLogRepo(),
|
||||
metrics=_FakeMetrics(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_success(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_content_blocked(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._content_filter = _FakeContentFilter(passed=False, violations=["bad"])
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_bot_loop_detected(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._bot_loop_guard = _FakeBotLoopGuard(allow=False)
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_session_none(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._session_resolver = _FakeSessionResolver(session=None)
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is False
|
||||
assert result.error == "session_error"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_circuit_open(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._circuit_breaker = _FakeCircuitBreaker(available=False)
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is False
|
||||
assert result.error == "circuit_open"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_agent_crash(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._delivery = _FakeDeliveryService(raise_error=AgentCrashError("crash"))
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is False
|
||||
assert result.error == "agent_crash"
|
||||
assert 1 in dispatch_service._circuit_breaker.failures
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_recoverable_error_re_raises(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._delivery = _FakeDeliveryService(raise_error=RecoverableError("retry"))
|
||||
with pytest.raises(RecoverableError):
|
||||
await dispatch_service.dispatch(payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_generic_error(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
dispatch_service._delivery = _FakeDeliveryService(raise_error=RuntimeError("boom"))
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is False
|
||||
assert "boom" in (result.error or "")
|
||||
assert 1 in dispatch_service._circuit_breaker.failures
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_records_success_on_circuit(dispatch_service: DispatchService, payload: dict) -> None:
|
||||
await dispatch_service.dispatch(payload)
|
||||
assert 1 in dispatch_service._circuit_breaker.successes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_no_agent_config_id_uses_session_agent_id(
|
||||
dispatch_service: DispatchService, payload: dict
|
||||
) -> None:
|
||||
payload.pop("agent_config_id")
|
||||
result = await dispatch_service.dispatch(payload)
|
||||
assert result.success is True
|
||||
@ -0,0 +1,166 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.inbound_service import InboundService
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
from yuxi.channel.domain.service.pipeline_builder import PipelineBuilder
|
||||
|
||||
|
||||
class _FakeMiddleware:
|
||||
def __init__(self, name: str) -> None:
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
async def process(self, ctx: MessageContext, call_next: Any) -> MessageContext:
|
||||
return await call_next(ctx)
|
||||
|
||||
|
||||
class _FakePipeline:
|
||||
def __init__(self, abort: bool = False, skip: bool = False) -> None:
|
||||
self._abort = abort
|
||||
self._skip = skip
|
||||
self.calls: list[MessageContext] = []
|
||||
|
||||
async def execute(self, ctx: MessageContext) -> MessageContext:
|
||||
self.calls.append(ctx)
|
||||
if self._abort:
|
||||
ctx.abort("test abort", "TEST_ABORT")
|
||||
if self._skip:
|
||||
ctx.skip()
|
||||
return ctx
|
||||
|
||||
@property
|
||||
def middlewares(self) -> list:
|
||||
return []
|
||||
|
||||
|
||||
class _FakeEventPublisher:
|
||||
def __init__(self) -> None:
|
||||
self.published: list[Any] = []
|
||||
|
||||
async def publish(self, event: Any) -> None:
|
||||
self.published.append(event)
|
||||
|
||||
|
||||
class _FakeMessageLogRepo:
|
||||
def __init__(self) -> None:
|
||||
self.logs: list[dict] = []
|
||||
|
||||
async def create_log(self, **kwargs: Any) -> Any:
|
||||
self.logs.append(kwargs)
|
||||
return Any
|
||||
|
||||
async def update_pipeline_result(self, **kwargs: Any) -> bool:
|
||||
self.logs.append(kwargs)
|
||||
return True
|
||||
|
||||
async def update_worker_result(self, **kwargs: Any) -> bool:
|
||||
return True
|
||||
|
||||
async def get_by_trace_id(self, trace_id: str) -> list:
|
||||
return []
|
||||
|
||||
async def get_by_message_id(self, message_id: str) -> list:
|
||||
return []
|
||||
|
||||
async def query_logs(self, query: Any) -> list:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unified_message() -> UnifiedMessage:
|
||||
return UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="User"),
|
||||
content="hello",
|
||||
metadata={"client_ip": "127.0.0.1"},
|
||||
raw_payload={"authorization": "Bearer token"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_passes_through(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline()
|
||||
service = InboundService(pipeline=pipeline)
|
||||
result = await service.submit(unified_message, channel_type="web", trace_id="trace-1")
|
||||
assert result.is_aborted is False
|
||||
assert result.is_skipped is False
|
||||
assert result.trace_id == "trace-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_aborted(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline(abort=True)
|
||||
service = InboundService(pipeline=pipeline)
|
||||
result = await service.submit(unified_message, channel_type="web")
|
||||
assert result.is_aborted is True
|
||||
assert result.abort_reason == "test abort"
|
||||
assert result.abort_code == "TEST_ABORT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_skipped(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline(skip=True)
|
||||
service = InboundService(pipeline=pipeline)
|
||||
result = await service.submit(unified_message, channel_type="web")
|
||||
assert result.is_skipped is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_publishes_blocked_event(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline(abort=True)
|
||||
events = _FakeEventPublisher()
|
||||
service = InboundService(pipeline=pipeline, event_publisher=events)
|
||||
await service.submit(unified_message, channel_type="web")
|
||||
assert len(events.published) == 1
|
||||
assert events.published[0].__class__.__name__ == "MessageBlocked"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_publishes_received_event(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline()
|
||||
events = _FakeEventPublisher()
|
||||
service = InboundService(pipeline=pipeline, event_publisher=events)
|
||||
await service.submit(unified_message, channel_type="web")
|
||||
assert len(events.published) == 1
|
||||
assert events.published[0].__class__.__name__ == "MessageReceived"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_no_event_on_skip(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline(skip=True)
|
||||
events = _FakeEventPublisher()
|
||||
service = InboundService(pipeline=pipeline, event_publisher=events)
|
||||
await service.submit(unified_message, channel_type="web")
|
||||
assert len(events.published) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_creates_log(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline()
|
||||
logs = _FakeMessageLogRepo()
|
||||
service = InboundService(pipeline=pipeline, message_log_repo=logs)
|
||||
await service.submit(unified_message, channel_type="web", trace_id="trace-1")
|
||||
assert len(logs.logs) >= 1
|
||||
assert logs.logs[0]["trace_id"] == "trace-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_updates_pipeline_log_on_abort(unified_message: UnifiedMessage) -> None:
|
||||
pipeline = _FakePipeline(abort=True)
|
||||
logs = _FakeMessageLogRepo()
|
||||
service = InboundService(pipeline=pipeline, message_log_repo=logs)
|
||||
await service.submit(unified_message, channel_type="web")
|
||||
log_types = [log.get("pipeline_result") for log in logs.logs if "pipeline_result" in log]
|
||||
assert "aborted" in log_types
|
||||
@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.application.service.session_resolver import SessionResolver
|
||||
from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
|
||||
from yuxi.channel.domain.model.session.channel_session import ChannelSession
|
||||
|
||||
|
||||
class _FakeSessionRepo:
|
||||
def __init__(self) -> None:
|
||||
self.sessions: dict[str, ChannelSession] = {}
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def get_or_create(
|
||||
self, *, channel_type: str, account_id: str, agent_config_id: int
|
||||
) -> ChannelSession:
|
||||
self.calls.append(
|
||||
{"channel_type": channel_type, "account_id": account_id, "agent_config_id": agent_config_id}
|
||||
)
|
||||
key = f"{channel_type}:{account_id}:{agent_config_id}"
|
||||
if key not in self.sessions:
|
||||
self.sessions[key] = ChannelSession(
|
||||
id=1,
|
||||
thread_id=key,
|
||||
user_id=account_id,
|
||||
agent_id=str(agent_config_id),
|
||||
channel_type=channel_type,
|
||||
channel_session_key=account_id,
|
||||
status="active",
|
||||
title=None,
|
||||
)
|
||||
return self.sessions[key]
|
||||
|
||||
async def get_by_thread_id(self, thread_id: str) -> ChannelSession | None:
|
||||
return None
|
||||
|
||||
async def count_active_sessions(self, channel_type: str) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
class _FakeBindingRepo:
|
||||
def __init__(self, binding: ChannelBinding | None = None) -> None:
|
||||
self._binding = binding
|
||||
self.calls: list[dict] = []
|
||||
|
||||
async def find_binding(
|
||||
self, *, channel_type: str, account_id: str, group_id: str
|
||||
) -> ChannelBinding | None:
|
||||
self.calls.append({"channel_type": channel_type, "account_id": account_id, "group_id": group_id})
|
||||
return self._binding
|
||||
|
||||
async def create_binding(
|
||||
self,
|
||||
*,
|
||||
channel_type: str,
|
||||
account_id: str,
|
||||
group_id: str,
|
||||
agent_config_id: int,
|
||||
created_by: str | None = None,
|
||||
) -> ChannelBinding:
|
||||
return ChannelBinding(
|
||||
id=1,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
group_id=group_id,
|
||||
agent_config_id=agent_config_id,
|
||||
)
|
||||
|
||||
async def update_binding(
|
||||
self,
|
||||
binding_id: int,
|
||||
*,
|
||||
agent_config_id: int | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
updated_by: str | None = None,
|
||||
) -> ChannelBinding | None:
|
||||
return None
|
||||
|
||||
async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool:
|
||||
return False
|
||||
|
||||
async def get_binding(self, binding_id: int) -> ChannelBinding | None:
|
||||
return None
|
||||
|
||||
async def list_bindings(
|
||||
self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50
|
||||
) -> tuple[list[ChannelBinding], int]:
|
||||
return [], 0
|
||||
|
||||
async def invalidate_cache(
|
||||
self, *, channel_type: str, account_id: str, group_id: str
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session_repo() -> _FakeSessionRepo:
|
||||
return _FakeSessionRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_binding_repo() -> _FakeBindingRepo:
|
||||
return _FakeBindingRepo()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolver(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> SessionResolver:
|
||||
return SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
session_key_strategy="auto",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_with_agent_config_id(resolver: SessionResolver) -> None:
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {"is_group": False},
|
||||
"agent_config_id": 42,
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert session.agent_id == "42"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_uses_binding_agent_config_id(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="web",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=99,
|
||||
)
|
||||
fake_binding_repo._binding = binding
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {"account_id": "acc-1", "group_id": "grp-1"},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert session.agent_id == "99"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_uses_default_agent_config_id(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=7,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert session.agent_id == "7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_key_auto_group(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {"is_group": True, "group_id": "grp-1"},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert fake_session_repo.calls[-1]["account_id"] == "user-1:web:grp-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_key_auto_dm(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {"is_group": False},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert fake_session_repo.calls[-1]["account_id"] == "user-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_key_main_strategy(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="web",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=1,
|
||||
session_key_strategy="main",
|
||||
)
|
||||
fake_binding_repo._binding = binding
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {"account_id": "acc-1", "group_id": "grp-1"},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert fake_session_repo.calls[-1]["account_id"] == "user-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_key_channel_group_strategy(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="web",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=1,
|
||||
session_key_strategy="channel_group",
|
||||
)
|
||||
fake_binding_repo._binding = binding
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {"account_id": "acc-1", "group_id": "grp-1"},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert fake_session_repo.calls[-1]["account_id"] == "user-1:web:grp-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_key_custom_strategy(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="web",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=1,
|
||||
session_key_strategy="custom",
|
||||
)
|
||||
fake_binding_repo._binding = binding
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {
|
||||
"account_id": "acc-1",
|
||||
"group_id": "grp-1",
|
||||
"session_key_prefix": "pre",
|
||||
"session_key": "custom-key",
|
||||
},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert fake_session_repo.calls[-1]["account_id"] == "pre:custom-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_key_custom_strategy_no_prefix(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="web",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=1,
|
||||
session_key_strategy="custom",
|
||||
)
|
||||
fake_binding_repo._binding = binding
|
||||
resolver = SessionResolver(
|
||||
session_repo=fake_session_repo,
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {
|
||||
"account_id": "acc-1",
|
||||
"group_id": "grp-1",
|
||||
"session_key": "custom-key",
|
||||
},
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is not None
|
||||
assert fake_session_repo.calls[-1]["account_id"] == "custom-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_session_creation_failure(
|
||||
fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo
|
||||
) -> None:
|
||||
class FailingRepo(_FakeSessionRepo):
|
||||
async def get_or_create(
|
||||
self, *, channel_type: str, account_id: str, agent_config_id: int
|
||||
) -> ChannelSession:
|
||||
raise RuntimeError("db error")
|
||||
|
||||
resolver = SessionResolver(
|
||||
session_repo=FailingRepo(),
|
||||
binding_repo=fake_binding_repo,
|
||||
default_agent_config_id=1,
|
||||
)
|
||||
payload = {
|
||||
"channel_type": "web",
|
||||
"sender_id": "user-1",
|
||||
"metadata": {},
|
||||
"message_id": "msg-1",
|
||||
"trace_id": "trace-1",
|
||||
}
|
||||
session = await resolver.resolve(payload)
|
||||
assert session is None
|
||||
1
backend/test/unit/channel/channels/__init__.py
Normal file
1
backend/test/unit/channel/channels/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/channels/dingtalk/__init__.py
Normal file
1
backend/test/unit/channel/channels/dingtalk/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/channels/feishu/__init__.py
Normal file
1
backend/test/unit/channel/channels/feishu/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/channels/hooks/__init__.py
Normal file
1
backend/test/unit/channel/channels/hooks/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/channels/web/__init__.py
Normal file
1
backend/test/unit/channel/channels/web/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/container/__init__.py
Normal file
1
backend/test/unit/channel/container/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainer
|
||||
|
||||
|
||||
class TestChannelContainerShutdown:
|
||||
@pytest.fixture
|
||||
def container(self):
|
||||
return ChannelContainer(
|
||||
pipeline=MagicMock(),
|
||||
inbound_service=MagicMock(),
|
||||
dispatch_service=MagicMock(),
|
||||
delivery_service=MagicMock(),
|
||||
session_resolver=MagicMock(),
|
||||
binding_service=MagicMock(),
|
||||
config_service=MagicMock(),
|
||||
auth_service=MagicMock(),
|
||||
channel_config=MagicMock(),
|
||||
worker_pool=MagicMock(),
|
||||
outbox_worker=MagicMock(),
|
||||
session_factory=MagicMock(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_closes_all_resources(self, container):
|
||||
container.event_publisher = AsyncMock()
|
||||
container.sse_endpoint = AsyncMock()
|
||||
container.pubsub_subscriber = AsyncMock()
|
||||
container.worker_pool = AsyncMock()
|
||||
container.outbox_worker = AsyncMock()
|
||||
container.ws_manager = AsyncMock()
|
||||
container.redis = AsyncMock()
|
||||
|
||||
adapter = AsyncMock()
|
||||
container.adapters = {"feishu": adapter}
|
||||
|
||||
await container.shutdown()
|
||||
|
||||
container.event_publisher.publish.assert_awaited_once()
|
||||
container.sse_endpoint.broadcast_shutdown.assert_awaited_once()
|
||||
container.pubsub_subscriber.stop.assert_awaited_once()
|
||||
container.worker_pool.stop.assert_awaited_once()
|
||||
container.outbox_worker.stop.assert_awaited_once()
|
||||
container.ws_manager.stop_all.assert_awaited_once()
|
||||
container.sse_endpoint.stop.assert_awaited_once()
|
||||
adapter.close.assert_awaited_once()
|
||||
container.redis.aclose.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_gracefully_handles_missing_resources(self, container):
|
||||
container.event_publisher = None
|
||||
container.sse_endpoint = None
|
||||
container.pubsub_subscriber = None
|
||||
container.worker_pool = None
|
||||
container.outbox_worker = None
|
||||
container.ws_manager = None
|
||||
container.redis = None
|
||||
container.adapters = {}
|
||||
|
||||
await container.shutdown()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_gracefully_handles_adapter_close_exception(self, container):
|
||||
adapter = AsyncMock()
|
||||
adapter.close.side_effect = RuntimeError("boom")
|
||||
container.adapters = {"feishu": adapter}
|
||||
|
||||
await container.shutdown()
|
||||
|
||||
adapter.close.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_skips_event_publish_on_exception(self, container):
|
||||
container.event_publisher = AsyncMock()
|
||||
container.event_publisher.publish.side_effect = RuntimeError("boom")
|
||||
|
||||
await container.shutdown()
|
||||
|
||||
container.event_publisher.publish.assert_awaited_once()
|
||||
@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory
|
||||
|
||||
|
||||
class TestApplyEnvOverrides:
|
||||
def test_feishu_env_overrides(self, monkeypatch):
|
||||
monkeypatch.setenv("FEISHU_APP_ID", "app-123")
|
||||
monkeypatch.setenv("FEISHU_APP_SECRET", "secret-456")
|
||||
|
||||
config = {"app_id": "", "app_secret": ""}
|
||||
overrides = ChannelContainerFactory._apply_env_overrides("feishu", config)
|
||||
|
||||
assert overrides["app_id"] == "app-123"
|
||||
assert overrides["app_secret"] == "secret-456"
|
||||
|
||||
def test_feishu_no_env_returns_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("FEISHU_APP_ID", raising=False)
|
||||
monkeypatch.delenv("FEISHU_APP_SECRET", raising=False)
|
||||
|
||||
config = {"app_id": "", "app_secret": ""}
|
||||
overrides = ChannelContainerFactory._apply_env_overrides("feishu", config)
|
||||
|
||||
assert overrides == {}
|
||||
|
||||
def test_dingtalk_env_overrides(self, monkeypatch):
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_ID", "client-123")
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "secret-456")
|
||||
|
||||
config = {"client_id": "", "client_secret": ""}
|
||||
overrides = ChannelContainerFactory._apply_env_overrides("dingtalk", config)
|
||||
|
||||
assert overrides["client_id"] == "client-123"
|
||||
assert overrides["client_secret"] == "secret-456"
|
||||
|
||||
def test_dingtalk_partial_env(self, monkeypatch):
|
||||
monkeypatch.setenv("DINGTALK_CLIENT_ID", "client-123")
|
||||
monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False)
|
||||
|
||||
config = {"client_id": "", "client_secret": ""}
|
||||
overrides = ChannelContainerFactory._apply_env_overrides("dingtalk", config)
|
||||
|
||||
assert overrides["client_id"] == "client-123"
|
||||
assert "client_secret" not in overrides
|
||||
|
||||
def test_unknown_channel_returns_empty(self):
|
||||
config = {"key": "value"}
|
||||
overrides = ChannelContainerFactory._apply_env_overrides("unknown", config)
|
||||
|
||||
assert overrides == {}
|
||||
@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
|
||||
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
|
||||
|
||||
|
||||
class TestBuildAdapters:
|
||||
@pytest.fixture
|
||||
def infra(self):
|
||||
return _InfraBundle(
|
||||
queue_port=MagicMock(),
|
||||
event_publisher=MagicMock(),
|
||||
content_filter=MagicMock(),
|
||||
config_reload=MagicMock(),
|
||||
cache_port=MagicMock(),
|
||||
rate_limit_port=MagicMock(),
|
||||
bot_loop_guard=MagicMock(),
|
||||
circuit_breaker=MagicMock(),
|
||||
metrics=MagicMock(),
|
||||
signature_verifier=MagicMock(),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("web:\n max_connections: 100\n")
|
||||
return ChannelConfig(str(config_file))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapters_returns_adapters_and_sse(self, infra, config):
|
||||
mock_adapter_cls = MagicMock()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter_cls.get_default_config.return_value = {"max_connections": 1000}
|
||||
mock_adapter_cls.return_value = mock_adapter
|
||||
|
||||
with patch("yuxi.channel.container.get_registered_channels", return_value={"web": mock_adapter_cls}):
|
||||
adapters, sse = await ChannelContainerFactory._build_adapters(config, infra)
|
||||
|
||||
assert "web" in adapters
|
||||
assert sse is not None
|
||||
mock_adapter.open.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapters_injects_sse_for_web(self, infra, config):
|
||||
mock_adapter_cls = MagicMock()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter_cls.get_default_config.return_value = {"max_connections": 1000}
|
||||
mock_adapter_cls.return_value = mock_adapter
|
||||
|
||||
with patch("yuxi.channel.container.get_registered_channels", return_value={"web": mock_adapter_cls}):
|
||||
adapters, sse = await ChannelContainerFactory._build_adapters(config, infra)
|
||||
|
||||
_, kwargs = mock_adapter_cls.call_args
|
||||
assert kwargs["sse_push"] is sse
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_adapters_injects_hook_mappings(self, infra, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("hooks:\n mappings:\n - match_path: /test\n")
|
||||
config = ChannelConfig(str(config_file))
|
||||
|
||||
mock_adapter_cls = MagicMock()
|
||||
mock_adapter = AsyncMock()
|
||||
mock_adapter_cls.get_default_config.return_value = {"mappings": []}
|
||||
mock_adapter_cls.return_value = mock_adapter
|
||||
|
||||
with patch("yuxi.channel.container.get_registered_channels", return_value={"hooks": mock_adapter_cls}):
|
||||
adapters, sse = await ChannelContainerFactory._build_adapters(config, infra)
|
||||
|
||||
_, kwargs = mock_adapter_cls.call_args
|
||||
assert "mappings" in kwargs
|
||||
@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
|
||||
from yuxi.channel.application.service.auth_service import AuthService
|
||||
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
|
||||
|
||||
|
||||
class TestBuildAuthService:
|
||||
@pytest.fixture
|
||||
def infra(self):
|
||||
return _InfraBundle(
|
||||
queue_port=MagicMock(),
|
||||
event_publisher=MagicMock(),
|
||||
content_filter=MagicMock(),
|
||||
config_reload=MagicMock(),
|
||||
cache_port=MagicMock(),
|
||||
rate_limit_port=MagicMock(),
|
||||
bot_loop_guard=MagicMock(),
|
||||
circuit_breaker=MagicMock(),
|
||||
metrics=MagicMock(),
|
||||
signature_verifier=MagicMock(),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text(
|
||||
"auth:\n token: test-token\n password: test-pwd\n max_attempts: 3\n lockout_seconds: 60\n"
|
||||
)
|
||||
return ChannelConfig(str(config_file))
|
||||
|
||||
def test_build_auth_service_uses_config_values(self, infra, config):
|
||||
auth = ChannelContainerFactory._build_auth_service(infra, config)
|
||||
|
||||
assert isinstance(auth, AuthService)
|
||||
assert auth._token == "test-token"
|
||||
assert auth._password == "test-pwd"
|
||||
assert auth._max_attempts == 3
|
||||
assert auth._lockout_seconds == 60
|
||||
|
||||
def test_build_auth_service_with_empty_config(self, infra, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("{}")
|
||||
config = ChannelConfig(str(config_file))
|
||||
|
||||
auth = ChannelContainerFactory._build_auth_service(infra, config)
|
||||
|
||||
assert isinstance(auth, AuthService)
|
||||
assert auth._token is None
|
||||
assert auth._password is None
|
||||
assert auth._max_attempts == 5
|
||||
assert auth._lockout_seconds == 300
|
||||
@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory
|
||||
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
|
||||
|
||||
|
||||
class TestBuildConfig:
|
||||
def test_build_config_returns_channel_config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("auth:\n token: test-token\n")
|
||||
|
||||
result = ChannelContainerFactory._build_config(str(config_file))
|
||||
|
||||
assert isinstance(result, ChannelConfig)
|
||||
assert result.auth_token == "test-token"
|
||||
|
||||
def test_build_config_with_missing_file_uses_defaults(self, tmp_path):
|
||||
config_file = tmp_path / "nonexistent.yaml"
|
||||
|
||||
result = ChannelContainerFactory._build_config(str(config_file))
|
||||
|
||||
assert isinstance(result, ChannelConfig)
|
||||
assert result.auth_token is None
|
||||
@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
|
||||
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
|
||||
|
||||
|
||||
class TestBuildInfra:
|
||||
@pytest.fixture
|
||||
def mock_redis(self):
|
||||
return MagicMock()
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("auth:\n token: test\n")
|
||||
return ChannelConfig(str(config_file))
|
||||
|
||||
def test_build_infra_returns_bundle(self, mock_redis, config):
|
||||
bundle = ChannelContainerFactory._build_infra(mock_redis, config)
|
||||
|
||||
assert isinstance(bundle, _InfraBundle)
|
||||
assert bundle.queue_port is not None
|
||||
assert bundle.event_publisher is not None
|
||||
assert bundle.content_filter is not None
|
||||
assert bundle.config_reload is not None
|
||||
assert bundle.cache_port is not None
|
||||
assert bundle.rate_limit_port is not None
|
||||
assert bundle.bot_loop_guard is not None
|
||||
assert bundle.circuit_breaker is not None
|
||||
assert bundle.metrics is not None
|
||||
assert bundle.signature_verifier is not None
|
||||
|
||||
def test_build_infra_uses_webhook_secret(self, mock_redis, config, monkeypatch):
|
||||
monkeypatch.setenv("WEBHOOK_SECRET", "my-secret")
|
||||
|
||||
bundle = ChannelContainerFactory._build_infra(mock_redis, config)
|
||||
|
||||
assert bundle.signature_verifier is not None
|
||||
@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle
|
||||
from yuxi.channel.application.service.auth_service import AuthService
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
|
||||
|
||||
|
||||
class TestBuildPipeline:
|
||||
@pytest.fixture
|
||||
def infra(self):
|
||||
return _InfraBundle(
|
||||
queue_port=MagicMock(),
|
||||
event_publisher=MagicMock(),
|
||||
content_filter=MagicMock(),
|
||||
config_reload=MagicMock(),
|
||||
cache_port=MagicMock(),
|
||||
rate_limit_port=MagicMock(),
|
||||
bot_loop_guard=MagicMock(),
|
||||
circuit_breaker=MagicMock(),
|
||||
metrics=MagicMock(),
|
||||
signature_verifier=MagicMock(),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def auth_service(self):
|
||||
return AuthService(MagicMock(), token="test")
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("keyword_blocklist:\n - badword\nallow_from:\n feishu: true\n")
|
||||
return ChannelConfig(str(config_file))
|
||||
|
||||
def test_build_pipeline_returns_pipeline(self, infra, auth_service, config):
|
||||
pipeline = ChannelContainerFactory._build_pipeline(infra, config, auth_service)
|
||||
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
assert len(pipeline.middlewares) > 0
|
||||
|
||||
def test_build_pipeline_with_empty_config(self, infra, auth_service, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("{}")
|
||||
config = ChannelConfig(str(config_file))
|
||||
|
||||
pipeline = ChannelContainerFactory._build_pipeline(infra, config, auth_service)
|
||||
|
||||
assert isinstance(pipeline, Pipeline)
|
||||
assert len(pipeline.middlewares) > 0
|
||||
@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory, _InfraBundle, _WorkerBundle
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
from yuxi.channel.infrastructure.config.channel_config import ChannelConfig
|
||||
|
||||
|
||||
class TestBuildWorkers:
|
||||
@pytest.fixture
|
||||
def infra(self):
|
||||
return _InfraBundle(
|
||||
queue_port=MagicMock(),
|
||||
event_publisher=MagicMock(),
|
||||
content_filter=MagicMock(),
|
||||
config_reload=MagicMock(),
|
||||
cache_port=MagicMock(),
|
||||
rate_limit_port=MagicMock(),
|
||||
bot_loop_guard=MagicMock(),
|
||||
circuit_breaker=MagicMock(),
|
||||
metrics=MagicMock(),
|
||||
signature_verifier=MagicMock(),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("{}")
|
||||
return ChannelConfig(str(config_file))
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(self):
|
||||
return MagicMock(spec=Pipeline)
|
||||
|
||||
@pytest.fixture
|
||||
def redis(self):
|
||||
return MagicMock()
|
||||
|
||||
def test_build_workers_returns_worker_bundle(self, infra, config, pipeline, redis):
|
||||
adapters = {}
|
||||
|
||||
with patch("yuxi.channel.container.pg_manager") as mock_pg:
|
||||
mock_pg.get_async_session_context = MagicMock()
|
||||
workers = ChannelContainerFactory._build_workers(
|
||||
infra, adapters, None, config, pipeline, redis
|
||||
)
|
||||
|
||||
assert isinstance(workers, _WorkerBundle)
|
||||
assert workers.dispatch_service is not None
|
||||
assert workers.binding_service is not None
|
||||
assert workers.config_service is not None
|
||||
assert workers.worker_pool is not None
|
||||
assert workers.outbox_worker is not None
|
||||
assert workers.session_factory is not None
|
||||
assert workers.binding_repo is not None
|
||||
assert workers.session_repo is not None
|
||||
assert workers.outbox_repo is not None
|
||||
assert workers.message_repo is not None
|
||||
assert workers.message_log_repo is not None
|
||||
|
||||
def test_build_workers_uses_provided_agent_port(self, infra, config, pipeline, redis):
|
||||
adapters = {}
|
||||
agent_port = MagicMock()
|
||||
|
||||
with patch("yuxi.channel.container.pg_manager") as mock_pg:
|
||||
mock_pg.get_async_session_context = MagicMock()
|
||||
workers = ChannelContainerFactory._build_workers(
|
||||
infra, adapters, agent_port, config, pipeline, redis
|
||||
)
|
||||
|
||||
assert workers.dispatch_service is not None
|
||||
|
||||
def test_build_workers_respects_mq_config(self, infra, config, pipeline, redis):
|
||||
adapters = {}
|
||||
|
||||
with patch("yuxi.channel.container.pg_manager") as mock_pg:
|
||||
mock_pg.get_async_session_context = MagicMock()
|
||||
workers = ChannelContainerFactory._build_workers(
|
||||
infra, adapters, None, config, pipeline, redis, mq_workers=2, mq_max_concurrent=10
|
||||
)
|
||||
|
||||
assert workers.worker_pool._config.num_workers == 2
|
||||
assert workers.worker_pool._config.max_concurrent == 10
|
||||
@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
from yuxi.channel.interfaces.websocket.manager import WsConnectionManager
|
||||
|
||||
|
||||
class TestBuildWsConnections:
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ws_connections_with_no_ws_adapters(self):
|
||||
pipeline = MagicMock(spec=Pipeline)
|
||||
tracer = MagicMock()
|
||||
adapters = {}
|
||||
|
||||
ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer)
|
||||
|
||||
assert isinstance(ws_manager, WsConnectionManager)
|
||||
assert ws_manager.connections_status == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ws_connections_registers_ws_adapters(self):
|
||||
pipeline = MagicMock(spec=Pipeline)
|
||||
tracer = MagicMock()
|
||||
|
||||
ws_conn = MagicMock()
|
||||
ws_conn.channel_type = "feishu"
|
||||
adapter = MagicMock()
|
||||
adapter.ws_connection = ws_conn
|
||||
|
||||
adapters = {"feishu": adapter}
|
||||
|
||||
with patch.object(WsConnectionManager, "start_all", new_callable=AsyncMock):
|
||||
ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer)
|
||||
|
||||
assert "feishu" in ws_manager._connections
|
||||
tracer.mark.assert_called_once_with("ws_feishu", "registered")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_ws_connections_skips_adapters_without_ws(self):
|
||||
pipeline = MagicMock(spec=Pipeline)
|
||||
tracer = MagicMock()
|
||||
|
||||
adapter = MagicMock()
|
||||
adapter.ws_connection = None
|
||||
|
||||
adapters = {"web": adapter}
|
||||
|
||||
with patch.object(WsConnectionManager, "start_all", new_callable=AsyncMock):
|
||||
ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer)
|
||||
|
||||
assert "web" not in ws_manager._connections
|
||||
tracer.mark.assert_not_called()
|
||||
@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainer, ChannelContainerFactory
|
||||
|
||||
|
||||
class TestChannelContainerFactoryCreate:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_returns_channel_container(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("web:\n max_connections: 100\n")
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("yuxi.channel.container.aioredis.from_url", return_value=mock_redis):
|
||||
with patch.object(
|
||||
ChannelContainerFactory, "_build_adapters", new_callable=AsyncMock
|
||||
) as mock_build_adapters:
|
||||
mock_adapter = AsyncMock()
|
||||
mock_build_adapters.return_value = ({"web": mock_adapter}, AsyncMock())
|
||||
|
||||
with patch.object(
|
||||
ChannelContainerFactory, "_build_ws_connections", new_callable=AsyncMock
|
||||
) as mock_build_ws:
|
||||
mock_build_ws.return_value = AsyncMock()
|
||||
|
||||
with patch("yuxi.channel.container.WorkerPool.start", new_callable=AsyncMock):
|
||||
with patch("yuxi.channel.container.OutboxRetryWorker.start", new_callable=AsyncMock):
|
||||
with patch("yuxi.channel.container.RedisPubSubSubscriber.start", new_callable=AsyncMock):
|
||||
with patch("yuxi.channel.container.pg_manager") as mock_pg:
|
||||
mock_pg.get_async_session_context = MagicMock()
|
||||
container = await ChannelContainerFactory.create(
|
||||
redis_url="redis://localhost:6379/0",
|
||||
config_yaml_path=str(config_file),
|
||||
)
|
||||
|
||||
assert isinstance(container, ChannelContainer)
|
||||
assert container.pipeline is not None
|
||||
assert container.inbound_service is not None
|
||||
assert container.startup_tracer is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_custom_mq_config(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("{}")
|
||||
|
||||
mock_redis = MagicMock()
|
||||
mock_redis.aclose = AsyncMock()
|
||||
|
||||
with patch("yuxi.channel.container.aioredis.from_url", return_value=mock_redis):
|
||||
with patch.object(
|
||||
ChannelContainerFactory, "_build_adapters", new_callable=AsyncMock
|
||||
) as mock_build_adapters:
|
||||
mock_adapter = AsyncMock()
|
||||
mock_build_adapters.return_value = ({}, AsyncMock())
|
||||
|
||||
with patch.object(
|
||||
ChannelContainerFactory, "_build_ws_connections", new_callable=AsyncMock
|
||||
) as mock_build_ws:
|
||||
mock_build_ws.return_value = AsyncMock()
|
||||
|
||||
with patch("yuxi.channel.container.WorkerPool.start", new_callable=AsyncMock):
|
||||
with patch("yuxi.channel.container.OutboxRetryWorker.start", new_callable=AsyncMock):
|
||||
with patch("yuxi.channel.container.RedisPubSubSubscriber.start", new_callable=AsyncMock):
|
||||
with patch("yuxi.channel.container.pg_manager") as mock_pg:
|
||||
mock_pg.get_async_session_context = MagicMock()
|
||||
container = await ChannelContainerFactory.create(
|
||||
redis_url="redis://localhost:6379/0",
|
||||
config_yaml_path=str(config_file),
|
||||
mq_workers=2,
|
||||
mq_max_concurrent=10,
|
||||
default_agent_config_id=5,
|
||||
)
|
||||
|
||||
assert container.worker_pool._config.num_workers == 2
|
||||
assert container.worker_pool._config.max_concurrent == 10
|
||||
@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import ChannelContainerFactory
|
||||
from yuxi.channel.domain.service.pipeline import Pipeline
|
||||
from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware
|
||||
|
||||
|
||||
class TestInjectBotIds:
|
||||
def test_inject_bot_ids_sets_bot_id_on_mention_gate(self):
|
||||
mention_gate = MentionGateMiddleware()
|
||||
pipeline = Pipeline([mention_gate])
|
||||
|
||||
adapter = MagicMock()
|
||||
adapter.bot_id = "bot-123"
|
||||
|
||||
ChannelContainerFactory._inject_bot_ids(pipeline, {"feishu": adapter})
|
||||
|
||||
assert mention_gate._bot_id == "bot-123"
|
||||
|
||||
def test_inject_bot_ids_skips_when_no_mention_gate(self):
|
||||
pipeline = Pipeline([])
|
||||
|
||||
adapter = MagicMock()
|
||||
adapter.bot_id = "bot-123"
|
||||
|
||||
ChannelContainerFactory._inject_bot_ids(pipeline, {"feishu": adapter})
|
||||
|
||||
def test_inject_bot_ids_skips_adapter_without_bot_id(self):
|
||||
mention_gate = MentionGateMiddleware()
|
||||
pipeline = Pipeline([mention_gate])
|
||||
|
||||
adapter = MagicMock()
|
||||
del adapter.bot_id
|
||||
|
||||
ChannelContainerFactory._inject_bot_ids(pipeline, {"feishu": adapter})
|
||||
|
||||
assert mention_gate._bot_id is None
|
||||
@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import _resolve_agent_id
|
||||
|
||||
|
||||
class TestResolveAgentId:
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_id_returns_agent_id(self):
|
||||
mock_config = MagicMock()
|
||||
mock_config.agent_id = "agent-123"
|
||||
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_by_id = AsyncMock(return_value=mock_config)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
with patch("yuxi.channel.container.AgentConfigRepository", return_value=mock_repo):
|
||||
result = await _resolve_agent_id(mock_session_factory, 1)
|
||||
|
||||
assert result == "agent-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_agent_id_returns_default_when_not_found(self):
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_by_id = AsyncMock(return_value=None)
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_factory.return_value = mock_session
|
||||
|
||||
with patch("yuxi.channel.container.AgentConfigRepository", return_value=mock_repo):
|
||||
result = await _resolve_agent_id(mock_session_factory, 999)
|
||||
|
||||
assert result == "chatbot"
|
||||
@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
|
||||
from yuxi.channel.container import ChannelContainer, get_channel, setup_channel
|
||||
|
||||
|
||||
class TestSetupChannel:
|
||||
def test_setup_channel_sets_state_on_fastapi(self):
|
||||
app = FastAPI()
|
||||
container = MagicMock(spec=ChannelContainer)
|
||||
|
||||
setup_channel(app, container)
|
||||
|
||||
assert app.state.channel is container
|
||||
|
||||
def test_setup_channel_ignores_non_fastapi(self):
|
||||
app = MagicMock()
|
||||
container = MagicMock(spec=ChannelContainer)
|
||||
|
||||
setup_channel(app, container)
|
||||
|
||||
assert not hasattr(app.state, "channel")
|
||||
|
||||
|
||||
class TestGetChannel:
|
||||
def test_get_channel_returns_container(self):
|
||||
app = FastAPI()
|
||||
container = MagicMock(spec=ChannelContainer)
|
||||
app.state.channel = container
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.app = app
|
||||
|
||||
result = get_channel(request)
|
||||
assert result is container
|
||||
|
||||
def test_get_channel_raises_when_not_initialized(self):
|
||||
app = FastAPI()
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.app = app
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
get_channel(request)
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.detail == "channel not initialized"
|
||||
@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.startup import get_container, init_channel, register_channel_middleware, shutdown_channel
|
||||
|
||||
|
||||
class TestInitChannel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_channel_sets_global_container(self, tmp_path):
|
||||
config_file = tmp_path / "channel_config.yaml"
|
||||
config_file.write_text("{}")
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.shutdown = AsyncMock()
|
||||
|
||||
with patch("yuxi.channel.startup.ChannelContainerFactory.create", new_callable=AsyncMock, return_value=mock_container):
|
||||
container = await init_channel(
|
||||
redis_url="redis://localhost:6379/0",
|
||||
config_yaml_path=str(config_file),
|
||||
)
|
||||
|
||||
assert container is mock_container
|
||||
assert get_container() is mock_container
|
||||
|
||||
await shutdown_channel()
|
||||
|
||||
|
||||
class TestShutdownChannel:
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_channel_calls_shutdown(self):
|
||||
mock_container = MagicMock()
|
||||
mock_container.shutdown = AsyncMock()
|
||||
|
||||
with patch("yuxi.channel.startup.ChannelContainerFactory.create", new_callable=AsyncMock, return_value=mock_container):
|
||||
await init_channel(redis_url="redis://localhost:6379/0")
|
||||
|
||||
await shutdown_channel()
|
||||
|
||||
mock_container.shutdown.assert_awaited_once()
|
||||
assert get_container() is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_channel_with_provided_container(self):
|
||||
mock_container = MagicMock()
|
||||
mock_container.shutdown = AsyncMock()
|
||||
|
||||
await shutdown_channel(mock_container)
|
||||
|
||||
mock_container.shutdown.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_channel_handles_none(self):
|
||||
await shutdown_channel(None)
|
||||
|
||||
|
||||
class TestGetContainer:
|
||||
def test_get_container_returns_none_initially(self):
|
||||
assert get_container() is None
|
||||
|
||||
|
||||
class TestRegisterChannelMiddleware:
|
||||
def test_register_channel_middleware_sets_state(self):
|
||||
mock_container = MagicMock()
|
||||
|
||||
with patch("yuxi.channel.startup._container", mock_container):
|
||||
app = MagicMock()
|
||||
register_channel_middleware(app)
|
||||
|
||||
assert app.state.channel is mock_container
|
||||
|
||||
def test_register_channel_middleware_skips_when_no_container(self):
|
||||
with patch("yuxi.channel.startup._container", None):
|
||||
app = MagicMock()
|
||||
register_channel_middleware(app)
|
||||
|
||||
assert not hasattr(app.state, "channel")
|
||||
@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.container import StartupTracer
|
||||
|
||||
|
||||
class TestStartupTracer:
|
||||
def test_begin_starts_phase_and_timer(self):
|
||||
tracer = StartupTracer()
|
||||
tracer.begin("config")
|
||||
assert tracer._current_phase == "config"
|
||||
assert tracer._start_time > 0
|
||||
|
||||
def test_end_records_event_with_duration(self):
|
||||
tracer = StartupTracer()
|
||||
tracer.begin("config")
|
||||
time.sleep(0.01)
|
||||
tracer.end()
|
||||
|
||||
assert len(tracer._events) == 1
|
||||
assert tracer._events[0]["name"] == "config"
|
||||
assert tracer._events[0]["status"] == "ok"
|
||||
assert tracer._events[0]["duration_ms"] >= 10
|
||||
|
||||
def test_mark_records_custom_event(self):
|
||||
tracer = StartupTracer()
|
||||
tracer.mark("ws_feishu", "registered", duration_ms=5.0)
|
||||
|
||||
assert len(tracer._events) == 1
|
||||
assert tracer._events[0]["name"] == "ws_feishu"
|
||||
assert tracer._events[0]["status"] == "registered"
|
||||
assert tracer._events[0]["duration_ms"] == 5.0
|
||||
|
||||
def test_total_ms_sums_all_durations(self):
|
||||
tracer = StartupTracer()
|
||||
tracer.mark("a", "ok", duration_ms=10.0)
|
||||
tracer.mark("b", "ok", duration_ms=20.0)
|
||||
|
||||
assert tracer.total_ms == 30.0
|
||||
|
||||
def test_to_dict_returns_copy(self):
|
||||
tracer = StartupTracer()
|
||||
tracer.mark("a", "ok", duration_ms=5.0)
|
||||
result = tracer.to_dict()
|
||||
|
||||
assert result == tracer._events
|
||||
result.append({"name": "b", "status": "ok", "duration_ms": 1.0})
|
||||
assert len(tracer._events) == 1
|
||||
|
||||
def test_multiple_phases(self):
|
||||
tracer = StartupTracer()
|
||||
tracer.begin("config")
|
||||
time.sleep(0.005)
|
||||
tracer.end()
|
||||
|
||||
tracer.begin("redis")
|
||||
time.sleep(0.005)
|
||||
tracer.end()
|
||||
|
||||
assert len(tracer._events) == 2
|
||||
assert tracer._events[0]["name"] == "config"
|
||||
assert tracer._events[1]["name"] == "redis"
|
||||
assert tracer.total_ms > 0
|
||||
1
backend/test/unit/channel/domain/__init__.py
Normal file
1
backend/test/unit/channel/domain/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/test/unit/channel/domain/event/__init__.py
Normal file
1
backend/test/unit/channel/domain/event/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
34
backend/test/unit/channel/domain/event/test_agent_error.py
Normal file
34
backend/test/unit/channel/domain/event/test_agent_error.py
Normal file
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.event.agent_error import AgentError
|
||||
|
||||
|
||||
def test_agent_error_defaults() -> None:
|
||||
error = AgentError(
|
||||
message_id="msg-1",
|
||||
channel_type="feishu",
|
||||
session_id="sess-1",
|
||||
error_message="something went wrong",
|
||||
)
|
||||
assert error.message_id == "msg-1"
|
||||
assert error.channel_type == "feishu"
|
||||
assert error.session_id == "sess-1"
|
||||
assert error.error_message == "something went wrong"
|
||||
assert error.agent_config_id is None
|
||||
assert error.trace_id == ""
|
||||
assert error.metadata == {}
|
||||
|
||||
|
||||
def test_agent_error_with_values() -> None:
|
||||
error = AgentError(
|
||||
message_id="msg-2",
|
||||
channel_type="dingtalk",
|
||||
session_id="sess-2",
|
||||
error_message="agent crashed",
|
||||
agent_config_id=42,
|
||||
trace_id="trace-1",
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
assert error.agent_config_id == 42
|
||||
assert error.trace_id == "trace-1"
|
||||
assert error.metadata == {"key": "value"}
|
||||
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.event.gateway_shutdown import GatewayShutdown
|
||||
|
||||
|
||||
def test_gateway_shutdown_defaults() -> None:
|
||||
event = GatewayShutdown()
|
||||
assert event.reason == "gateway_shutting_down"
|
||||
assert event.metadata == {}
|
||||
|
||||
|
||||
def test_gateway_shutdown_with_values() -> None:
|
||||
event = GatewayShutdown(
|
||||
reason="manual_restart",
|
||||
metadata={"version": "1.0.0"},
|
||||
)
|
||||
assert event.reason == "manual_restart"
|
||||
assert event.metadata == {"version": "1.0.0"}
|
||||
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.event.message_blocked import MessageBlocked
|
||||
|
||||
|
||||
def test_message_blocked_defaults() -> None:
|
||||
event = MessageBlocked(
|
||||
message_id="msg-1",
|
||||
channel_type="feishu",
|
||||
session_id="sess-1",
|
||||
reason="keyword_filter",
|
||||
)
|
||||
assert event.message_id == "msg-1"
|
||||
assert event.channel_type == "feishu"
|
||||
assert event.session_id == "sess-1"
|
||||
assert event.reason == "keyword_filter"
|
||||
assert event.abort_code == ""
|
||||
assert event.trace_id == ""
|
||||
assert event.metadata == {}
|
||||
|
||||
|
||||
def test_message_blocked_with_values() -> None:
|
||||
event = MessageBlocked(
|
||||
message_id="msg-2",
|
||||
channel_type="web",
|
||||
session_id="sess-2",
|
||||
reason="rate_limited",
|
||||
abort_code="RATE_LIMITED",
|
||||
trace_id="trace-1",
|
||||
metadata={"limit": 100},
|
||||
)
|
||||
assert event.abort_code == "RATE_LIMITED"
|
||||
assert event.trace_id == "trace-1"
|
||||
assert event.metadata == {"limit": 100}
|
||||
@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.event.message_received import MessageReceived
|
||||
|
||||
|
||||
def test_message_received_defaults() -> None:
|
||||
event = MessageReceived(
|
||||
message_id="msg-1",
|
||||
channel_type="feishu",
|
||||
session_id="sess-1",
|
||||
sender_id="user-1",
|
||||
)
|
||||
assert event.message_id == "msg-1"
|
||||
assert event.channel_type == "feishu"
|
||||
assert event.session_id == "sess-1"
|
||||
assert event.sender_id == "user-1"
|
||||
assert event.content_summary == ""
|
||||
assert event.trace_id == ""
|
||||
assert event.metadata == {}
|
||||
|
||||
|
||||
def test_message_received_with_values() -> None:
|
||||
event = MessageReceived(
|
||||
message_id="msg-2",
|
||||
channel_type="dingtalk",
|
||||
session_id="sess-2",
|
||||
sender_id="user-2",
|
||||
content_summary="hello world",
|
||||
trace_id="trace-1",
|
||||
metadata={"ip": "127.0.0.1"},
|
||||
)
|
||||
assert event.content_summary == "hello world"
|
||||
assert event.trace_id == "trace-1"
|
||||
assert event.metadata == {"ip": "127.0.0.1"}
|
||||
@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.event.message_replied import MessageReplied
|
||||
|
||||
|
||||
def test_message_replied_defaults() -> None:
|
||||
event = MessageReplied(
|
||||
message_id="msg-1",
|
||||
channel_type="feishu",
|
||||
session_id="sess-1",
|
||||
)
|
||||
assert event.message_id == "msg-1"
|
||||
assert event.channel_type == "feishu"
|
||||
assert event.session_id == "sess-1"
|
||||
assert event.reply_content_summary == ""
|
||||
assert event.trace_id == ""
|
||||
assert event.metadata == {}
|
||||
|
||||
|
||||
def test_message_replied_with_values() -> None:
|
||||
event = MessageReplied(
|
||||
message_id="msg-2",
|
||||
channel_type="web",
|
||||
session_id="sess-2",
|
||||
reply_content_summary="reply content",
|
||||
trace_id="trace-1",
|
||||
metadata={"latency_ms": 150},
|
||||
)
|
||||
assert event.reply_content_summary == "reply content"
|
||||
assert event.trace_id == "trace-1"
|
||||
assert event.metadata == {"latency_ms": 150}
|
||||
1
backend/test/unit/channel/domain/exception/__init__.py
Normal file
1
backend/test/unit/channel/domain/exception/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP
|
||||
|
||||
|
||||
def test_abort_code_mapping_keys() -> None:
|
||||
assert "AUTH_FAILED" in ABORT_CODE_TO_HTTP
|
||||
assert "RATE_LIMITED" in ABORT_CODE_TO_HTTP
|
||||
assert "VALIDATION_ERROR" in ABORT_CODE_TO_HTTP
|
||||
|
||||
|
||||
def test_abort_code_mapping_values() -> None:
|
||||
assert ABORT_CODE_TO_HTTP["AUTH_FAILED"] == 401
|
||||
assert ABORT_CODE_TO_HTTP["AUTH_RATE_LIMITED"] == 429
|
||||
assert ABORT_CODE_TO_HTTP["SIGNATURE_ERROR"] == 401
|
||||
assert ABORT_CODE_TO_HTTP["VALIDATION_ERROR"] == 400
|
||||
assert ABORT_CODE_TO_HTTP["PAYLOAD_TOO_LARGE"] == 413
|
||||
assert ABORT_CODE_TO_HTTP["SENDER_NOT_ALLOWED"] == 403
|
||||
assert ABORT_CODE_TO_HTTP["KEYWORD_BLOCKED"] == 400
|
||||
assert ABORT_CODE_TO_HTTP["DM_NOT_ALLOWED"] == 403
|
||||
assert ABORT_CODE_TO_HTTP["DM_DISABLED"] == 403
|
||||
assert ABORT_CODE_TO_HTTP["GROUP_NOT_ALLOWED"] == 403
|
||||
assert ABORT_CODE_TO_HTTP["GROUP_DISABLED"] == 403
|
||||
assert ABORT_CODE_TO_HTTP["PAIRING_REQUIRED"] == 403
|
||||
assert ABORT_CODE_TO_HTTP["RATE_LIMITED"] == 429
|
||||
assert ABORT_CODE_TO_HTTP["DEDUP_SKIPPED"] == 200
|
||||
|
||||
|
||||
def test_abort_code_mapping_is_dict() -> None:
|
||||
assert isinstance(ABORT_CODE_TO_HTTP, dict)
|
||||
assert len(ABORT_CODE_TO_HTTP) == 14
|
||||
@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from yuxi.channel.domain.exception.channel_error import ChannelError
|
||||
from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError
|
||||
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
|
||||
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
|
||||
|
||||
|
||||
def test_channel_error_is_exception() -> None:
|
||||
assert issubclass(ChannelError, Exception)
|
||||
|
||||
|
||||
def test_channel_error_can_be_raised() -> None:
|
||||
with pytest.raises(ChannelError, match="boom"):
|
||||
raise ChannelError("boom")
|
||||
|
||||
|
||||
def test_agent_crash_error_is_channel_error() -> None:
|
||||
assert issubclass(AgentCrashError, ChannelError)
|
||||
|
||||
|
||||
def test_agent_crash_error_can_be_raised() -> None:
|
||||
with pytest.raises(AgentCrashError, match="crash"):
|
||||
raise AgentCrashError("crash")
|
||||
|
||||
|
||||
def test_recoverable_error_is_channel_error() -> None:
|
||||
assert issubclass(RecoverableError, ChannelError)
|
||||
|
||||
|
||||
def test_recoverable_error_can_be_raised() -> None:
|
||||
with pytest.raises(RecoverableError, match="recover"):
|
||||
raise RecoverableError("recover")
|
||||
|
||||
|
||||
def test_unrecoverable_error_is_channel_error() -> None:
|
||||
assert issubclass(UnrecoverableError, ChannelError)
|
||||
|
||||
|
||||
def test_unrecoverable_error_can_be_raised() -> None:
|
||||
with pytest.raises(UnrecoverableError, match="fatal"):
|
||||
raise UnrecoverableError("fatal")
|
||||
1
backend/test/unit/channel/domain/middleware/__init__.py
Normal file
1
backend/test/unit/channel/domain/middleware/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.middleware.configurable import Configurable
|
||||
|
||||
|
||||
def test_configurable_is_protocol() -> None:
|
||||
assert hasattr(Configurable, "on_config_updated")
|
||||
|
||||
|
||||
class _FakeConfigurable:
|
||||
def on_config_updated(self, config: dict) -> list[str]:
|
||||
return ["updated"]
|
||||
|
||||
|
||||
def test_fake_configurable_is_instance() -> None:
|
||||
obj = _FakeConfigurable()
|
||||
assert isinstance(obj, Configurable)
|
||||
@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.middleware.middleware import Middleware, CallNext
|
||||
from yuxi.channel.domain.service.message_context import MessageContext
|
||||
|
||||
|
||||
def test_middleware_is_protocol() -> None:
|
||||
assert hasattr(Middleware, "name")
|
||||
assert hasattr(Middleware, "process")
|
||||
|
||||
|
||||
class _FakeMiddleware:
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "fake"
|
||||
|
||||
async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext:
|
||||
return await call_next(ctx)
|
||||
|
||||
|
||||
def test_fake_middleware_is_instance() -> None:
|
||||
obj = _FakeMiddleware()
|
||||
assert isinstance(obj, Middleware)
|
||||
1
backend/test/unit/channel/domain/model/__init__.py
Normal file
1
backend/test/unit/channel/domain/model/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
|
||||
|
||||
|
||||
def test_channel_binding_defaults() -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="feishu",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=5,
|
||||
)
|
||||
assert binding.id == 1
|
||||
assert binding.channel_type == "feishu"
|
||||
assert binding.account_id == "acc-1"
|
||||
assert binding.group_id == "grp-1"
|
||||
assert binding.agent_config_id == 5
|
||||
assert binding.session_key_strategy == "auto"
|
||||
assert binding.is_enabled is True
|
||||
assert binding.created_by is None
|
||||
assert binding.updated_by is None
|
||||
assert binding.created_at == ""
|
||||
assert binding.updated_at == ""
|
||||
|
||||
|
||||
def test_channel_binding_with_values() -> None:
|
||||
binding = ChannelBinding(
|
||||
id=2,
|
||||
channel_type="web",
|
||||
account_id="acc-2",
|
||||
group_id="grp-2",
|
||||
agent_config_id=10,
|
||||
session_key_strategy="main",
|
||||
is_enabled=False,
|
||||
created_by="admin",
|
||||
updated_by="admin",
|
||||
created_at="2024-01-01",
|
||||
updated_at="2024-01-02",
|
||||
)
|
||||
assert binding.session_key_strategy == "main"
|
||||
assert binding.is_enabled is False
|
||||
assert binding.created_by == "admin"
|
||||
|
||||
|
||||
def test_resolve_session_key_strategy_auto_with_group() -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="feishu",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=5,
|
||||
session_key_strategy="auto",
|
||||
)
|
||||
assert binding.resolve_session_key_strategy() == "channel_group"
|
||||
|
||||
|
||||
def test_resolve_session_key_strategy_auto_without_group() -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="feishu",
|
||||
account_id="acc-1",
|
||||
group_id="",
|
||||
agent_config_id=5,
|
||||
session_key_strategy="auto",
|
||||
)
|
||||
assert binding.resolve_session_key_strategy() == "main"
|
||||
|
||||
|
||||
def test_resolve_session_key_strategy_explicit() -> None:
|
||||
binding = ChannelBinding(
|
||||
id=1,
|
||||
channel_type="feishu",
|
||||
account_id="acc-1",
|
||||
group_id="grp-1",
|
||||
agent_config_id=5,
|
||||
session_key_strategy="custom",
|
||||
)
|
||||
assert binding.resolve_session_key_strategy() == "custom"
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.message.attachment import Attachment
|
||||
|
||||
|
||||
def test_attachment_defaults() -> None:
|
||||
att = Attachment(url="http://example.com/file.jpg", media_type="image")
|
||||
assert att.url == "http://example.com/file.jpg"
|
||||
assert att.media_type == "image"
|
||||
assert att.filename == ""
|
||||
assert att.size_bytes == 0
|
||||
assert att.mime_type == ""
|
||||
|
||||
|
||||
def test_attachment_with_values() -> None:
|
||||
att = Attachment(
|
||||
url="http://example.com/doc.pdf",
|
||||
media_type="file",
|
||||
filename="doc.pdf",
|
||||
size_bytes=1024,
|
||||
mime_type="application/pdf",
|
||||
)
|
||||
assert att.filename == "doc.pdf"
|
||||
assert att.size_bytes == 1024
|
||||
assert att.mime_type == "application/pdf"
|
||||
@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, SendResult
|
||||
|
||||
|
||||
def test_dispatch_result_success() -> None:
|
||||
result = DispatchResult(success=True, message_id="msg-1")
|
||||
assert result.success is True
|
||||
assert result.message_id == "msg-1"
|
||||
assert result.error is None
|
||||
|
||||
|
||||
def test_dispatch_result_failure() -> None:
|
||||
result = DispatchResult(success=False, message_id="msg-2", error="timeout")
|
||||
assert result.success is False
|
||||
assert result.error == "timeout"
|
||||
|
||||
|
||||
def test_send_result_success() -> None:
|
||||
result = SendResult(success=True)
|
||||
assert result.success is True
|
||||
assert result.error == ""
|
||||
|
||||
|
||||
def test_send_result_failure() -> None:
|
||||
result = SendResult(success=False, error="failed")
|
||||
assert result.success is False
|
||||
assert result.error == "failed"
|
||||
|
||||
|
||||
def test_send_result_is_frozen() -> None:
|
||||
result = SendResult(success=True)
|
||||
assert result.success is True
|
||||
15
backend/test/unit/channel/domain/model/message/test_peer.py
Normal file
15
backend/test/unit/channel/domain/model/message/test_peer.py
Normal file
@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
|
||||
|
||||
def test_peer_defaults() -> None:
|
||||
peer = Peer(id="user-1", name="Alice")
|
||||
assert peer.id == "user-1"
|
||||
assert peer.name == "Alice"
|
||||
assert peer.kind == "user"
|
||||
|
||||
|
||||
def test_peer_with_kind() -> None:
|
||||
peer = Peer(id="bot-1", name="Bot", kind="assistant")
|
||||
assert peer.kind == "assistant"
|
||||
@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
|
||||
|
||||
def test_stream_chat_request_defaults() -> None:
|
||||
req = StreamChatRequest(
|
||||
message_id="msg-1",
|
||||
session_id="sess-1",
|
||||
agent_config_id=5,
|
||||
content="hello",
|
||||
channel_type=ChannelType.FEISHU,
|
||||
)
|
||||
assert req.message_id == "msg-1"
|
||||
assert req.session_id == "sess-1"
|
||||
assert req.agent_config_id == 5
|
||||
assert req.content == "hello"
|
||||
assert req.channel_type == ChannelType.FEISHU
|
||||
assert req.metadata == {}
|
||||
|
||||
|
||||
def test_stream_chat_request_with_metadata() -> None:
|
||||
req = StreamChatRequest(
|
||||
message_id="msg-2",
|
||||
session_id="sess-2",
|
||||
agent_config_id=10,
|
||||
content="world",
|
||||
channel_type=ChannelType.WEB,
|
||||
metadata={"key": "value"},
|
||||
)
|
||||
assert req.metadata == {"key": "value"}
|
||||
@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from yuxi.channel.domain.model.message.attachment import Attachment
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
from yuxi.channel.domain.model.shared.message_priority import MessagePriority
|
||||
from yuxi.channel.domain.model.shared.message_type import MessageType
|
||||
|
||||
|
||||
def test_unified_message_defaults() -> None:
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.FEISHU,
|
||||
sender=Peer(id="user-1", name="Alice"),
|
||||
content="hello",
|
||||
)
|
||||
assert msg.message_id == "msg-1"
|
||||
assert msg.channel_type == ChannelType.FEISHU
|
||||
assert msg.sender.id == "user-1"
|
||||
assert msg.content == "hello"
|
||||
assert msg.content_type == MessageType.TEXT
|
||||
assert msg.session_id is None
|
||||
assert msg.agent_config_id is None
|
||||
assert msg.metadata == {}
|
||||
assert msg.raw_payload == {}
|
||||
assert isinstance(msg.timestamp, datetime)
|
||||
assert msg.priority == MessagePriority.NORMAL
|
||||
assert msg.reply_to is None
|
||||
assert msg.attachments == []
|
||||
|
||||
|
||||
def test_unified_message_with_values() -> None:
|
||||
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
msg = UnifiedMessage(
|
||||
message_id="msg-2",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-2", name="Bob"),
|
||||
content="world",
|
||||
content_type=MessageType.MARKDOWN,
|
||||
session_id="sess-1",
|
||||
agent_config_id=42,
|
||||
metadata={"key": "value"},
|
||||
raw_payload={"original": "data"},
|
||||
timestamp=ts,
|
||||
priority=MessagePriority.HIGH,
|
||||
reply_to="msg-1",
|
||||
attachments=[Attachment(url="http://example.com", media_type="image")],
|
||||
)
|
||||
assert msg.content_type == MessageType.MARKDOWN
|
||||
assert msg.session_id == "sess-1"
|
||||
assert msg.agent_config_id == 42
|
||||
assert msg.metadata == {"key": "value"}
|
||||
assert msg.raw_payload == {"original": "data"}
|
||||
assert msg.timestamp == ts
|
||||
assert msg.priority == MessagePriority.HIGH
|
||||
assert msg.reply_to == "msg-1"
|
||||
assert len(msg.attachments) == 1
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry
|
||||
|
||||
|
||||
def test_outbox_entry_creation() -> None:
|
||||
entry = OutboxEntry(
|
||||
id=1,
|
||||
message_id="msg-1",
|
||||
session_id="sess-1",
|
||||
channel_type="feishu",
|
||||
content="hello",
|
||||
status="pending",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
next_retry_at=None,
|
||||
last_error=None,
|
||||
trace_id=None,
|
||||
extra_metadata=None,
|
||||
)
|
||||
assert entry.id == 1
|
||||
assert entry.message_id == "msg-1"
|
||||
assert entry.session_id == "sess-1"
|
||||
assert entry.channel_type == "feishu"
|
||||
assert entry.content == "hello"
|
||||
assert entry.status == "pending"
|
||||
assert entry.retry_count == 0
|
||||
assert entry.max_retries == 3
|
||||
assert entry.next_retry_at is None
|
||||
assert entry.last_error is None
|
||||
assert entry.trace_id is None
|
||||
assert entry.extra_metadata is None
|
||||
|
||||
|
||||
def test_outbox_entry_with_values() -> None:
|
||||
entry = OutboxEntry(
|
||||
id=2,
|
||||
message_id="msg-2",
|
||||
session_id="sess-2",
|
||||
channel_type="web",
|
||||
content="world",
|
||||
status="retrying",
|
||||
retry_count=1,
|
||||
max_retries=5,
|
||||
next_retry_at="2024-01-01T12:00:00",
|
||||
last_error="timeout",
|
||||
trace_id="trace-1",
|
||||
extra_metadata={"key": "value"},
|
||||
)
|
||||
assert entry.status == "retrying"
|
||||
assert entry.retry_count == 1
|
||||
assert entry.next_retry_at == "2024-01-01T12:00:00"
|
||||
assert entry.last_error == "timeout"
|
||||
assert entry.trace_id == "trace-1"
|
||||
assert entry.extra_metadata == {"key": "value"}
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.session.channel_session import ChannelSession
|
||||
|
||||
|
||||
def test_channel_session_creation() -> None:
|
||||
session = ChannelSession(
|
||||
id=1,
|
||||
thread_id="thread-1",
|
||||
user_id="user-1",
|
||||
agent_id="agent-1",
|
||||
channel_type="feishu",
|
||||
channel_session_key="key-1",
|
||||
status="active",
|
||||
title="Test Session",
|
||||
)
|
||||
assert session.id == 1
|
||||
assert session.thread_id == "thread-1"
|
||||
assert session.user_id == "user-1"
|
||||
assert session.agent_id == "agent-1"
|
||||
assert session.channel_type == "feishu"
|
||||
assert session.channel_session_key == "key-1"
|
||||
assert session.status == "active"
|
||||
assert session.title == "Test Session"
|
||||
|
||||
|
||||
def test_channel_session_with_none_values() -> None:
|
||||
session = ChannelSession(
|
||||
id=2,
|
||||
thread_id="thread-2",
|
||||
user_id="user-2",
|
||||
agent_id="agent-2",
|
||||
channel_type=None,
|
||||
channel_session_key=None,
|
||||
status="closed",
|
||||
title=None,
|
||||
)
|
||||
assert session.channel_type is None
|
||||
assert session.channel_session_key is None
|
||||
assert session.status == "closed"
|
||||
assert session.title is None
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
|
||||
|
||||
|
||||
def test_channel_capabilities_defaults() -> None:
|
||||
caps = ChannelCapabilities()
|
||||
assert caps.media is False
|
||||
assert caps.group is False
|
||||
assert caps.dm is True
|
||||
assert caps.streaming is False
|
||||
assert caps.typing is False
|
||||
assert caps.reaction is False
|
||||
assert caps.thread is False
|
||||
assert caps.max_text_length == 4096
|
||||
|
||||
|
||||
def test_channel_capabilities_custom() -> None:
|
||||
caps = ChannelCapabilities(
|
||||
media=True,
|
||||
group=True,
|
||||
dm=False,
|
||||
streaming=True,
|
||||
typing=True,
|
||||
reaction=True,
|
||||
thread=True,
|
||||
max_text_length=2000,
|
||||
)
|
||||
assert caps.media is True
|
||||
assert caps.group is True
|
||||
assert caps.dm is False
|
||||
assert caps.streaming is True
|
||||
assert caps.typing is True
|
||||
assert caps.reaction is True
|
||||
assert caps.thread is True
|
||||
assert caps.max_text_length == 2000
|
||||
|
||||
|
||||
def test_channel_capabilities_is_frozen() -> None:
|
||||
caps = ChannelCapabilities()
|
||||
assert caps.dm is True
|
||||
@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
|
||||
|
||||
def test_channel_type_values() -> None:
|
||||
assert ChannelType.FEISHU == "feishu"
|
||||
assert ChannelType.DINGTALK == "dingtalk"
|
||||
assert ChannelType.WEB == "web"
|
||||
assert ChannelType.HOOKS == "hooks"
|
||||
|
||||
|
||||
def test_channel_type_is_strenum() -> None:
|
||||
assert issubclass(ChannelType, str)
|
||||
@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.shared.content_chunker import chunk_content
|
||||
|
||||
|
||||
def test_chunk_content_short() -> None:
|
||||
result = chunk_content("hello", max_length=10)
|
||||
assert result == ["hello"]
|
||||
|
||||
|
||||
def test_chunk_content_exact_length() -> None:
|
||||
result = chunk_content("1234567890", max_length=10)
|
||||
assert result == ["1234567890"]
|
||||
|
||||
|
||||
def test_chunk_content_split() -> None:
|
||||
result = chunk_content("1234567890abcdef", max_length=10)
|
||||
assert result == ["1234567890", "abcdef"]
|
||||
|
||||
|
||||
def test_chunk_content_multiple_splits() -> None:
|
||||
result = chunk_content("a" * 25, max_length=10)
|
||||
assert result == ["a" * 10, "a" * 10, "a" * 5]
|
||||
|
||||
|
||||
def test_chunk_content_default_max_length() -> None:
|
||||
result = chunk_content("x" * 4096)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "x" * 4096
|
||||
|
||||
|
||||
def test_chunk_content_exceeds_default() -> None:
|
||||
result = chunk_content("x" * 4097)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "x" * 4096
|
||||
assert result[1] == "x"
|
||||
@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.shared.message_priority import MessagePriority
|
||||
|
||||
|
||||
def test_message_priority_values() -> None:
|
||||
assert MessagePriority.NORMAL == "normal"
|
||||
assert MessagePriority.HIGH == "high"
|
||||
assert MessagePriority.LOW == "low"
|
||||
|
||||
|
||||
def test_message_priority_is_strenum() -> None:
|
||||
assert issubclass(MessagePriority, str)
|
||||
@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.model.shared.message_type import MessageType
|
||||
|
||||
|
||||
def test_message_type_values() -> None:
|
||||
assert MessageType.TEXT == "text"
|
||||
assert MessageType.IMAGE == "image"
|
||||
assert MessageType.FILE == "file"
|
||||
assert MessageType.MARKDOWN == "markdown"
|
||||
|
||||
|
||||
def test_message_type_is_strenum() -> None:
|
||||
assert issubclass(MessageType, str)
|
||||
1
backend/test/unit/channel/domain/port/__init__.py
Normal file
1
backend/test/unit/channel/domain/port/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
25
backend/test/unit/channel/domain/port/test_agent_port.py
Normal file
25
backend/test/unit/channel/domain/port/test_agent_port.py
Normal file
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from yuxi.channel.domain.port.agent_port import AgentPort
|
||||
from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
|
||||
|
||||
def test_agent_port_is_protocol() -> None:
|
||||
assert hasattr(AgentPort, "stream_chat")
|
||||
assert hasattr(AgentPort, "stream_chat_iter")
|
||||
|
||||
|
||||
class _FakeAgentPort:
|
||||
async def stream_chat(self, request: StreamChatRequest) -> str:
|
||||
return "response"
|
||||
|
||||
async def stream_chat_iter(self, request: StreamChatRequest) -> AsyncGenerator[str, None]:
|
||||
yield "chunk"
|
||||
|
||||
|
||||
def test_fake_agent_port_is_instance() -> None:
|
||||
obj = _FakeAgentPort()
|
||||
assert isinstance(obj, AgentPort)
|
||||
@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort
|
||||
|
||||
|
||||
def test_bot_loop_guard_port_is_protocol() -> None:
|
||||
assert hasattr(BotLoopGuardPort, "check")
|
||||
assert hasattr(BotLoopGuardPort, "reset")
|
||||
|
||||
|
||||
class _FakeBotLoopGuard:
|
||||
async def check(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
async def reset(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_bot_loop_guard_is_instance() -> None:
|
||||
obj = _FakeBotLoopGuard()
|
||||
assert isinstance(obj, BotLoopGuardPort)
|
||||
41
backend/test/unit/channel/domain/port/test_cache_port.py
Normal file
41
backend/test/unit/channel/domain/port/test_cache_port.py
Normal file
@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.cache_port import CachePort
|
||||
|
||||
|
||||
def test_cache_port_is_protocol() -> None:
|
||||
assert hasattr(CachePort, "get")
|
||||
assert hasattr(CachePort, "set")
|
||||
assert hasattr(CachePort, "delete")
|
||||
assert hasattr(CachePort, "incr")
|
||||
assert hasattr(CachePort, "expire")
|
||||
assert hasattr(CachePort, "ttl")
|
||||
assert hasattr(CachePort, "eval")
|
||||
|
||||
|
||||
class _FakeCache:
|
||||
async def get(self, key: str) -> str | None:
|
||||
return None
|
||||
|
||||
async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool = False) -> bool:
|
||||
return True
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
async def incr(self, key: str) -> int:
|
||||
return 1
|
||||
|
||||
async def expire(self, key: str, seconds: int) -> None:
|
||||
pass
|
||||
|
||||
async def ttl(self, key: str) -> int:
|
||||
return -1
|
||||
|
||||
async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple:
|
||||
return ()
|
||||
|
||||
|
||||
def test_fake_cache_is_instance() -> None:
|
||||
obj = _FakeCache()
|
||||
assert isinstance(obj, CachePort)
|
||||
@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
|
||||
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
|
||||
from yuxi.channel.domain.model.message.dispatch_result import SendResult
|
||||
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
|
||||
from yuxi.channel.domain.model.message.peer import Peer
|
||||
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
||||
|
||||
|
||||
def test_channel_adapter_port_is_protocol() -> None:
|
||||
assert hasattr(ChannelAdapterPort, "open")
|
||||
assert hasattr(ChannelAdapterPort, "close")
|
||||
assert hasattr(ChannelAdapterPort, "receive_message")
|
||||
assert hasattr(ChannelAdapterPort, "send_message")
|
||||
assert hasattr(ChannelAdapterPort, "send_typing")
|
||||
assert hasattr(ChannelAdapterPort, "send_media")
|
||||
assert hasattr(ChannelAdapterPort, "is_healthy")
|
||||
assert hasattr(ChannelAdapterPort, "capabilities")
|
||||
assert hasattr(ChannelAdapterPort, "channel_type")
|
||||
assert hasattr(ChannelAdapterPort, "ws_connection")
|
||||
assert hasattr(ChannelAdapterPort, "get_default_config")
|
||||
|
||||
|
||||
class _FakeChannelAdapter:
|
||||
async def open(self) -> None:
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
|
||||
async def receive_message(self, raw: dict) -> UnifiedMessage:
|
||||
return UnifiedMessage(
|
||||
message_id="msg-1",
|
||||
channel_type=ChannelType.WEB,
|
||||
sender=Peer(id="user-1", name="Alice"),
|
||||
content="hello",
|
||||
)
|
||||
|
||||
async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult:
|
||||
return SendResult(success=True)
|
||||
|
||||
async def send_typing(self, session_id: str) -> None:
|
||||
pass
|
||||
|
||||
async def send_media(self, session_id: str, *, url: str, media_type: str, metadata: dict) -> bool:
|
||||
return True
|
||||
|
||||
async def is_healthy(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def capabilities(self) -> ChannelCapabilities:
|
||||
return ChannelCapabilities()
|
||||
|
||||
@property
|
||||
def channel_type(self) -> str:
|
||||
return "web"
|
||||
|
||||
@property
|
||||
def ws_connection(self) -> None:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_default_config(cls) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def test_fake_channel_adapter_is_instance() -> None:
|
||||
obj = _FakeChannelAdapter()
|
||||
assert isinstance(obj, ChannelAdapterPort)
|
||||
@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
|
||||
|
||||
|
||||
def test_channel_route_contributor_is_protocol() -> None:
|
||||
assert hasattr(ChannelRouteContributor, "router")
|
||||
|
||||
|
||||
class _FakeContributor:
|
||||
@property
|
||||
def router(self) -> object:
|
||||
return {}
|
||||
|
||||
|
||||
def test_fake_contributor_is_instance() -> None:
|
||||
obj = _FakeContributor()
|
||||
assert isinstance(obj, ChannelRouteContributor)
|
||||
@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort
|
||||
|
||||
|
||||
def test_circuit_breaker_port_is_protocol() -> None:
|
||||
assert hasattr(CircuitBreakerPort, "is_available")
|
||||
assert hasattr(CircuitBreakerPort, "record_success")
|
||||
assert hasattr(CircuitBreakerPort, "record_failure")
|
||||
|
||||
|
||||
class _FakeCircuitBreaker:
|
||||
async def is_available(self, agent_config_id: int) -> bool:
|
||||
return True
|
||||
|
||||
async def record_success(self, agent_config_id: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_failure(self, agent_config_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_circuit_breaker_is_instance() -> None:
|
||||
obj = _FakeCircuitBreaker()
|
||||
assert isinstance(obj, CircuitBreakerPort)
|
||||
@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort
|
||||
|
||||
|
||||
def test_config_reload_port_is_protocol() -> None:
|
||||
assert hasattr(ConfigReloadPort, "watch")
|
||||
assert hasattr(ConfigReloadPort, "reload")
|
||||
assert hasattr(ConfigReloadPort, "get_current")
|
||||
|
||||
|
||||
class _FakeConfigReload:
|
||||
async def watch(self, callback: Callable[[dict], Awaitable[None]]) -> None:
|
||||
pass
|
||||
|
||||
async def reload(self) -> dict:
|
||||
return {}
|
||||
|
||||
async def get_current(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def test_fake_config_reload_is_instance() -> None:
|
||||
obj = _FakeConfigReload()
|
||||
assert isinstance(obj, ConfigReloadPort)
|
||||
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult
|
||||
|
||||
|
||||
def test_filter_result_defaults() -> None:
|
||||
result = FilterResult(passed=True)
|
||||
assert result.passed is True
|
||||
assert result.violations == []
|
||||
assert result.masked_fields == []
|
||||
assert result.filtered_content == ""
|
||||
|
||||
|
||||
def test_filter_result_with_values() -> None:
|
||||
result = FilterResult(
|
||||
passed=False,
|
||||
violations=["bad_word"],
|
||||
masked_fields=["content"],
|
||||
filtered_content="***",
|
||||
)
|
||||
assert result.passed is False
|
||||
assert result.violations == ["bad_word"]
|
||||
assert result.masked_fields == ["content"]
|
||||
assert result.filtered_content == "***"
|
||||
|
||||
|
||||
def test_content_filter_port_is_protocol() -> None:
|
||||
assert hasattr(ContentFilterPort, "check")
|
||||
|
||||
|
||||
class _FakeContentFilter:
|
||||
async def check(self, content: str, *, channel_type: str = "") -> FilterResult:
|
||||
return FilterResult(passed=True)
|
||||
|
||||
|
||||
def test_fake_content_filter_is_instance() -> None:
|
||||
obj = _FakeContentFilter()
|
||||
assert isinstance(obj, ContentFilterPort)
|
||||
@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort, DomainEvent
|
||||
from yuxi.channel.domain.event.message_received import MessageReceived
|
||||
|
||||
|
||||
def test_event_publisher_port_is_protocol() -> None:
|
||||
assert hasattr(EventPublisherPort, "publish")
|
||||
|
||||
|
||||
class _FakeEventPublisher:
|
||||
async def publish(self, event: DomainEvent) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_event_publisher_is_instance() -> None:
|
||||
obj = _FakeEventPublisher()
|
||||
assert isinstance(obj, EventPublisherPort)
|
||||
|
||||
|
||||
def test_domain_event_union() -> None:
|
||||
event = MessageReceived(
|
||||
message_id="msg-1",
|
||||
channel_type="feishu",
|
||||
session_id="sess-1",
|
||||
sender_id="user-1",
|
||||
)
|
||||
assert isinstance(event, MessageReceived)
|
||||
@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort
|
||||
|
||||
|
||||
def test_keyword_matcher_port_is_protocol() -> None:
|
||||
assert hasattr(KeywordMatcherPort, "match")
|
||||
assert hasattr(KeywordMatcherPort, "update_keywords")
|
||||
|
||||
|
||||
class _FakeKeywordMatcher:
|
||||
def match(self, content: str) -> str | None:
|
||||
return None
|
||||
|
||||
def update_keywords(self, keywords: set[str]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_keyword_matcher_is_instance() -> None:
|
||||
obj = _FakeKeywordMatcher()
|
||||
assert isinstance(obj, KeywordMatcherPort)
|
||||
85
backend/test/unit/channel/domain/port/test_metrics_port.py
Normal file
85
backend/test/unit/channel/domain/port/test_metrics_port.py
Normal file
@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.metrics_port import MetricsPort
|
||||
|
||||
|
||||
def test_metrics_port_is_protocol() -> None:
|
||||
assert hasattr(MetricsPort, "record_middleware_duration")
|
||||
assert hasattr(MetricsPort, "record_pipeline_duration")
|
||||
assert hasattr(MetricsPort, "record_pipeline_total")
|
||||
assert hasattr(MetricsPort, "record_pipeline_aborted")
|
||||
assert hasattr(MetricsPort, "record_worker_dispatch_duration")
|
||||
assert hasattr(MetricsPort, "record_worker_dispatch_total")
|
||||
assert hasattr(MetricsPort, "record_worker_step_duration")
|
||||
assert hasattr(MetricsPort, "set_outbox_pending")
|
||||
assert hasattr(MetricsPort, "record_outbox_enqueued")
|
||||
assert hasattr(MetricsPort, "set_sse_connections")
|
||||
assert hasattr(MetricsPort, "record_auth_attempts")
|
||||
assert hasattr(MetricsPort, "record_bot_loop_blocked")
|
||||
assert hasattr(MetricsPort, "record_access_policy_blocked")
|
||||
assert hasattr(MetricsPort, "record_mention_gate_skipped")
|
||||
assert hasattr(MetricsPort, "record_hooks_received")
|
||||
assert hasattr(MetricsPort, "record_content_filter_blocked")
|
||||
assert hasattr(MetricsPort, "set_circuit_breaker_state")
|
||||
assert hasattr(MetricsPort, "record_circuit_breaker_rejected")
|
||||
|
||||
|
||||
class _FakeMetrics:
|
||||
async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_total(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None:
|
||||
pass
|
||||
|
||||
async def set_outbox_pending(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_outbox_enqueued(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_sse_connections(self, count: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_auth_attempts(self, result: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_mention_gate_skipped(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_hooks_received(self, match_path: str) -> None:
|
||||
pass
|
||||
|
||||
async def record_content_filter_blocked(self, channel_type: str) -> None:
|
||||
pass
|
||||
|
||||
async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None:
|
||||
pass
|
||||
|
||||
async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_metrics_is_instance() -> None:
|
||||
obj = _FakeMetrics()
|
||||
assert isinstance(obj, MetricsPort)
|
||||
29
backend/test/unit/channel/domain/port/test_queue_port.py
Normal file
29
backend/test/unit/channel/domain/port/test_queue_port.py
Normal file
@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.queue_port import QueuePort
|
||||
|
||||
|
||||
def test_queue_port_is_protocol() -> None:
|
||||
assert hasattr(QueuePort, "enqueue")
|
||||
assert hasattr(QueuePort, "dequeue")
|
||||
assert hasattr(QueuePort, "ack")
|
||||
assert hasattr(QueuePort, "pending_count")
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
async def enqueue(self, payload: dict) -> str:
|
||||
return "msg-id"
|
||||
|
||||
async def dequeue(self, *, count: int = 1, block: int = 5000, consumer_name: str = "") -> list[dict]:
|
||||
return []
|
||||
|
||||
async def ack(self, message_id: str) -> None:
|
||||
pass
|
||||
|
||||
async def pending_count(self) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def test_fake_queue_is_instance() -> None:
|
||||
obj = _FakeQueue()
|
||||
assert isinstance(obj, QueuePort)
|
||||
@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.rate_limit_port import RateLimitPort
|
||||
|
||||
|
||||
def test_rate_limit_port_is_protocol() -> None:
|
||||
assert hasattr(RateLimitPort, "check_and_incr")
|
||||
assert hasattr(RateLimitPort, "is_locked")
|
||||
assert hasattr(RateLimitPort, "reset")
|
||||
|
||||
|
||||
class _FakeRateLimit:
|
||||
async def check_and_incr(
|
||||
self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def is_locked(self, key: str) -> tuple[bool, int]:
|
||||
return (False, 0)
|
||||
|
||||
async def reset(self, key: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_rate_limit_is_instance() -> None:
|
||||
obj = _FakeRateLimit()
|
||||
assert isinstance(obj, RateLimitPort)
|
||||
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort
|
||||
|
||||
|
||||
def test_signature_verify_port_is_protocol() -> None:
|
||||
assert hasattr(SignatureVerifyPort, "verify")
|
||||
|
||||
|
||||
class _FakeSignatureVerify:
|
||||
async def verify(self, payload: dict, signature: str, timestamp: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def test_fake_signature_verify_is_instance() -> None:
|
||||
obj = _FakeSignatureVerify()
|
||||
assert isinstance(obj, SignatureVerifyPort)
|
||||
17
backend/test/unit/channel/domain/port/test_sse_push_port.py
Normal file
17
backend/test/unit/channel/domain/port/test_sse_push_port.py
Normal file
@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.port.sse_push_port import SsePushPort
|
||||
|
||||
|
||||
def test_sse_push_port_is_protocol() -> None:
|
||||
assert hasattr(SsePushPort, "push_event")
|
||||
|
||||
|
||||
class _FakeSsePush:
|
||||
async def push_event(self, session_id: str, data: dict) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def test_fake_sse_push_is_instance() -> None:
|
||||
obj = _FakeSsePush()
|
||||
assert isinstance(obj, SsePushPort)
|
||||
@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
|
||||
|
||||
|
||||
def test_ws_connection_port_is_protocol() -> None:
|
||||
assert hasattr(WsConnectionPort, "channel_type")
|
||||
assert hasattr(WsConnectionPort, "is_connected")
|
||||
assert hasattr(WsConnectionPort, "supports_ack")
|
||||
assert hasattr(WsConnectionPort, "start")
|
||||
assert hasattr(WsConnectionPort, "stop")
|
||||
|
||||
|
||||
class _FakeWsConnection:
|
||||
@property
|
||||
def channel_type(self) -> str:
|
||||
return "web"
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_ack(self) -> bool:
|
||||
return False
|
||||
|
||||
async def start(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
on_message: Callable[[dict], Awaitable[None]],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_ws_connection_is_instance() -> None:
|
||||
obj = _FakeWsConnection()
|
||||
assert isinstance(obj, WsConnectionPort)
|
||||
|
||||
|
||||
def test_ws_connection_default_supports_ack() -> None:
|
||||
class _MinimalWsConnection:
|
||||
@property
|
||||
def channel_type(self) -> str:
|
||||
return "web"
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return False
|
||||
|
||||
async def start(
|
||||
self,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
on_message: Callable[[dict], Awaitable[None]],
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
async def stop(self) -> None:
|
||||
pass
|
||||
|
||||
obj = _MinimalWsConnection()
|
||||
assert obj.supports_ack is False
|
||||
1
backend/test/unit/channel/domain/repository/__init__.py
Normal file
1
backend/test/unit/channel/domain/repository/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort
|
||||
from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
|
||||
|
||||
|
||||
def test_binding_repository_port_is_protocol() -> None:
|
||||
assert hasattr(BindingRepositoryPort, "find_binding")
|
||||
assert hasattr(BindingRepositoryPort, "create_binding")
|
||||
assert hasattr(BindingRepositoryPort, "update_binding")
|
||||
assert hasattr(BindingRepositoryPort, "delete_binding")
|
||||
assert hasattr(BindingRepositoryPort, "get_binding")
|
||||
assert hasattr(BindingRepositoryPort, "list_bindings")
|
||||
assert hasattr(BindingRepositoryPort, "invalidate_cache")
|
||||
|
||||
|
||||
class _FakeBindingRepo:
|
||||
async def find_binding(
|
||||
self, *, channel_type: str, account_id: str, group_id: str
|
||||
) -> ChannelBinding | None:
|
||||
return None
|
||||
|
||||
async def create_binding(
|
||||
self,
|
||||
*,
|
||||
channel_type: str,
|
||||
account_id: str,
|
||||
group_id: str,
|
||||
agent_config_id: int,
|
||||
created_by: str | None = None,
|
||||
) -> ChannelBinding:
|
||||
return ChannelBinding(
|
||||
id=1,
|
||||
channel_type=channel_type,
|
||||
account_id=account_id,
|
||||
group_id=group_id,
|
||||
agent_config_id=agent_config_id,
|
||||
)
|
||||
|
||||
async def update_binding(
|
||||
self,
|
||||
binding_id: int,
|
||||
*,
|
||||
agent_config_id: int | None = None,
|
||||
is_enabled: bool | None = None,
|
||||
updated_by: str | None = None,
|
||||
) -> ChannelBinding | None:
|
||||
return None
|
||||
|
||||
async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool:
|
||||
return True
|
||||
|
||||
async def get_binding(self, binding_id: int) -> ChannelBinding | None:
|
||||
return None
|
||||
|
||||
async def list_bindings(
|
||||
self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50
|
||||
) -> tuple[list[ChannelBinding], int]:
|
||||
return [], 0
|
||||
|
||||
async def invalidate_cache(
|
||||
self, *, channel_type: str, account_id: str, group_id: str
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_fake_binding_repo_is_instance() -> None:
|
||||
obj = _FakeBindingRepo()
|
||||
assert isinstance(obj, BindingRepositoryPort)
|
||||
@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.repository.message_log_repository import (
|
||||
MessageLogRepositoryPort,
|
||||
MessageLogData,
|
||||
MessageLogQuery,
|
||||
)
|
||||
|
||||
|
||||
def test_message_log_data_creation() -> None:
|
||||
log = MessageLogData(
|
||||
id=1,
|
||||
trace_id="trace-1",
|
||||
message_id="msg-1",
|
||||
channel_type="feishu",
|
||||
conversation_id=1,
|
||||
session_id="sess-1",
|
||||
direction="inbound",
|
||||
sender_id="user-1",
|
||||
content_summary="hello",
|
||||
agent_config_id=5,
|
||||
status="success",
|
||||
pipeline_result="ok",
|
||||
abort_reason=None,
|
||||
worker_result="ok",
|
||||
error_message=None,
|
||||
processing_time_ms=100,
|
||||
)
|
||||
assert log.id == 1
|
||||
assert log.trace_id == "trace-1"
|
||||
assert log.direction == "inbound"
|
||||
|
||||
|
||||
def test_message_log_query_defaults() -> None:
|
||||
query = MessageLogQuery()
|
||||
assert query.channel_type is None
|
||||
assert query.conversation_id is None
|
||||
assert query.status is None
|
||||
assert query.direction is None
|
||||
assert query.pipeline_result is None
|
||||
assert query.worker_result is None
|
||||
assert query.limit == 50
|
||||
assert query.offset == 0
|
||||
|
||||
|
||||
def test_message_log_query_with_values() -> None:
|
||||
query = MessageLogQuery(
|
||||
channel_type="web",
|
||||
status="success",
|
||||
limit=10,
|
||||
offset=5,
|
||||
)
|
||||
assert query.channel_type == "web"
|
||||
assert query.status == "success"
|
||||
assert query.limit == 10
|
||||
assert query.offset == 5
|
||||
|
||||
|
||||
def test_message_log_repository_port_is_protocol() -> None:
|
||||
assert hasattr(MessageLogRepositoryPort, "create_log")
|
||||
assert hasattr(MessageLogRepositoryPort, "update_pipeline_result")
|
||||
assert hasattr(MessageLogRepositoryPort, "update_worker_result")
|
||||
assert hasattr(MessageLogRepositoryPort, "get_by_trace_id")
|
||||
assert hasattr(MessageLogRepositoryPort, "get_by_message_id")
|
||||
assert hasattr(MessageLogRepositoryPort, "query_logs")
|
||||
|
||||
|
||||
class _FakeMessageLogRepo:
|
||||
async def create_log(
|
||||
self,
|
||||
*,
|
||||
trace_id: str,
|
||||
message_id: str,
|
||||
channel_type: str,
|
||||
direction: str,
|
||||
sender_id: str | None = None,
|
||||
content_summary: str | None = None,
|
||||
agent_config_id: int | None = None,
|
||||
session_id: str | None = None,
|
||||
conversation_id: int | None = None,
|
||||
) -> MessageLogData:
|
||||
return MessageLogData(
|
||||
id=1,
|
||||
trace_id=trace_id,
|
||||
message_id=message_id,
|
||||
channel_type=channel_type,
|
||||
conversation_id=conversation_id,
|
||||
session_id=session_id,
|
||||
direction=direction,
|
||||
sender_id=sender_id,
|
||||
content_summary=content_summary,
|
||||
agent_config_id=agent_config_id,
|
||||
status="pending",
|
||||
pipeline_result=None,
|
||||
abort_reason=None,
|
||||
worker_result=None,
|
||||
error_message=None,
|
||||
processing_time_ms=None,
|
||||
)
|
||||
|
||||
async def update_pipeline_result(
|
||||
self,
|
||||
*,
|
||||
trace_id: str,
|
||||
message_id: str,
|
||||
pipeline_result: str,
|
||||
abort_reason: str | None = None,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def update_worker_result(
|
||||
self,
|
||||
*,
|
||||
trace_id: str,
|
||||
message_id: str,
|
||||
worker_result: str,
|
||||
status: str,
|
||||
error_message: str | None = None,
|
||||
processing_time_ms: int | None = None,
|
||||
agent_config_id: int | None = None,
|
||||
session_id: int | None = None,
|
||||
conversation_id: int | None = None,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
async def get_by_trace_id(self, trace_id: str) -> list[MessageLogData]:
|
||||
return []
|
||||
|
||||
async def get_by_message_id(self, message_id: str) -> list[MessageLogData]:
|
||||
return []
|
||||
|
||||
async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]:
|
||||
return []
|
||||
|
||||
|
||||
def test_fake_message_log_repo_is_instance() -> None:
|
||||
obj = _FakeMessageLogRepo()
|
||||
assert isinstance(obj, MessageLogRepositoryPort)
|
||||
@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.repository.message_repository import MessageRepositoryPort, ChannelMessageData
|
||||
|
||||
|
||||
def test_channel_message_data_creation() -> None:
|
||||
msg = ChannelMessageData(
|
||||
id=1,
|
||||
thread_id="thread-1",
|
||||
role="user",
|
||||
content="hello",
|
||||
message_id="msg-1",
|
||||
extra_metadata={"key": "value"},
|
||||
)
|
||||
assert msg.id == 1
|
||||
assert msg.thread_id == "thread-1"
|
||||
assert msg.role == "user"
|
||||
assert msg.content == "hello"
|
||||
assert msg.message_id == "msg-1"
|
||||
assert msg.extra_metadata == {"key": "value"}
|
||||
|
||||
|
||||
def test_message_repository_port_is_protocol() -> None:
|
||||
assert hasattr(MessageRepositoryPort, "save_user_message")
|
||||
assert hasattr(MessageRepositoryPort, "save_assistant_message")
|
||||
|
||||
|
||||
class _FakeMessageRepo:
|
||||
async def save_user_message(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
content: str,
|
||||
message_id: str | None = None,
|
||||
extra_metadata: dict | None = None,
|
||||
) -> ChannelMessageData:
|
||||
return ChannelMessageData(
|
||||
id=1,
|
||||
thread_id=thread_id,
|
||||
role="user",
|
||||
content=content,
|
||||
message_id=message_id,
|
||||
extra_metadata=extra_metadata,
|
||||
)
|
||||
|
||||
async def save_assistant_message(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
content: str,
|
||||
extra_metadata: dict | None = None,
|
||||
) -> ChannelMessageData:
|
||||
return ChannelMessageData(
|
||||
id=2,
|
||||
thread_id=thread_id,
|
||||
role="assistant",
|
||||
content=content,
|
||||
message_id=None,
|
||||
extra_metadata=extra_metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_fake_message_repo_is_instance() -> None:
|
||||
obj = _FakeMessageRepo()
|
||||
assert isinstance(obj, MessageRepositoryPort)
|
||||
@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort
|
||||
from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry
|
||||
|
||||
|
||||
def test_outbox_repository_port_is_protocol() -> None:
|
||||
assert hasattr(OutboxRepositoryPort, "enqueue")
|
||||
assert hasattr(OutboxRepositoryPort, "fetch_pending")
|
||||
assert hasattr(OutboxRepositoryPort, "get_by_id")
|
||||
assert hasattr(OutboxRepositoryPort, "mark_retrying")
|
||||
assert hasattr(OutboxRepositoryPort, "mark_sent")
|
||||
assert hasattr(OutboxRepositoryPort, "mark_dead")
|
||||
assert hasattr(OutboxRepositoryPort, "mark_failed")
|
||||
assert hasattr(OutboxRepositoryPort, "list_outbox")
|
||||
|
||||
|
||||
class _FakeOutboxRepo:
|
||||
async def enqueue(
|
||||
self,
|
||||
*,
|
||||
message_id: str,
|
||||
session_id: str,
|
||||
channel_type: str,
|
||||
content: str,
|
||||
trace_id: str | None = None,
|
||||
extra_metadata: dict | None = None,
|
||||
) -> OutboxEntry:
|
||||
return OutboxEntry(
|
||||
id=1,
|
||||
message_id=message_id,
|
||||
session_id=session_id,
|
||||
channel_type=channel_type,
|
||||
content=content,
|
||||
status="pending",
|
||||
retry_count=0,
|
||||
max_retries=3,
|
||||
next_retry_at=None,
|
||||
last_error=None,
|
||||
trace_id=trace_id,
|
||||
extra_metadata=extra_metadata,
|
||||
)
|
||||
|
||||
async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]:
|
||||
return []
|
||||
|
||||
async def get_by_id(self, entry_id: int) -> OutboxEntry | None:
|
||||
return None
|
||||
|
||||
async def mark_retrying(self, entry_id: int, *, last_error: str | None = None) -> OutboxEntry | None:
|
||||
return None
|
||||
|
||||
async def mark_sent(self, entry_id: int) -> bool:
|
||||
return True
|
||||
|
||||
async def mark_dead(self, entry_id: int, *, last_error: str) -> bool:
|
||||
return True
|
||||
|
||||
async def mark_failed(self, entry_id: int, *, last_error: str) -> bool:
|
||||
return True
|
||||
|
||||
async def list_outbox(
|
||||
self,
|
||||
*,
|
||||
channel_type: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[OutboxEntry]:
|
||||
return []
|
||||
|
||||
|
||||
def test_fake_outbox_repo_is_instance() -> None:
|
||||
obj = _FakeOutboxRepo()
|
||||
assert isinstance(obj, OutboxRepositoryPort)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user