新增了插件相关的完整领域模型、应用服务、基础设施实现,包括: 1. 插件状态、注册模式、来源等基础枚举和数据结构 2. 插件清单解析、发现、加载工具类 3. 插件注册表领域服务和内存存储实现 4. 插件相关的命令、查询、事件定义 5. 插件REST API接口和DTO映射 6. 集成了原有通道适配器到插件系统 7. 新增内置插件注册和自动发现能力
198 lines
7.2 KiB
Python
198 lines
7.2 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from yuxi.channel.channels.hooks.adapter import HooksAdapter, HOOKS_CAPABILITIES
|
|
from yuxi.channel.channels.hooks.config import HookMapping, HooksConfig
|
|
from yuxi.channel.channels.hooks.translator import HooksTranslator
|
|
from yuxi.channel.domain.model.shared.channel_type import ChannelType
|
|
|
|
|
|
class TestHookMapping:
|
|
def test_defaults(self) -> None:
|
|
m = HookMapping(match_path="/test")
|
|
assert m.match_path == "/test"
|
|
assert m.match_source == "*"
|
|
assert m.default_agent_id == 1
|
|
assert m.allowed_agent_ids == []
|
|
assert m.default_session_key == "main"
|
|
assert m.allow_request_session_key is False
|
|
assert m.allowed_session_key_prefixes == []
|
|
assert m.session_key_strategy == "main"
|
|
assert m.deliver is True
|
|
assert m.max_body_bytes == 256 * 1024
|
|
assert m.secret is None
|
|
|
|
def test_with_values(self) -> None:
|
|
m = HookMapping(
|
|
match_path="/webhook",
|
|
match_source="github",
|
|
default_agent_id=5,
|
|
secret="s3cret",
|
|
max_body_bytes=1024,
|
|
)
|
|
assert m.match_source == "github"
|
|
assert m.default_agent_id == 5
|
|
assert m.secret == "s3cret"
|
|
assert m.max_body_bytes == 1024
|
|
|
|
|
|
class TestHooksConfig:
|
|
def test_defaults(self) -> None:
|
|
config = HooksConfig()
|
|
assert config.mappings == []
|
|
|
|
def test_with_mappings(self) -> None:
|
|
m = HookMapping(match_path="/h1")
|
|
config = HooksConfig(mappings=[m])
|
|
assert len(config.mappings) == 1
|
|
|
|
|
|
class TestHooksTranslator:
|
|
def test_translate_basic(self) -> None:
|
|
mapping = HookMapping(match_path="/test", default_agent_id=3)
|
|
raw = {"id": "evt-1", "content": "hello"}
|
|
msg = HooksTranslator.translate(raw, mapping)
|
|
assert msg.message_id == "evt-1"
|
|
assert msg.content == "hello"
|
|
assert msg.channel_type == ChannelType.HOOKS
|
|
assert msg.agent_config_id == 3
|
|
assert msg.metadata["source"] == "hooks"
|
|
assert msg.metadata["match_path"] == "/test"
|
|
|
|
def test_translate_uses_text_fallback(self) -> None:
|
|
mapping = HookMapping(match_path="/test")
|
|
raw = {"text": "fallback text"}
|
|
msg = HooksTranslator.translate(raw, mapping)
|
|
assert msg.content == "fallback text"
|
|
|
|
def test_translate_agent_id_from_raw(self) -> None:
|
|
mapping = HookMapping(match_path="/test", default_agent_id=1, allowed_agent_ids=[1, 2, 3])
|
|
raw = {"id": "e1", "content": "hi", "agent_id": 2}
|
|
msg = HooksTranslator.translate(raw, mapping)
|
|
assert msg.agent_config_id == 2
|
|
|
|
def test_translate_agent_id_not_allowed_falls_back(self) -> None:
|
|
mapping = HookMapping(match_path="/test", default_agent_id=1, allowed_agent_ids=[1, 2])
|
|
raw = {"id": "e1", "content": "hi", "agent_id": 99}
|
|
msg = HooksTranslator.translate(raw, mapping)
|
|
assert msg.agent_config_id == 1
|
|
|
|
def test_translate_session_key_from_request(self) -> None:
|
|
mapping = HookMapping(
|
|
match_path="/test",
|
|
allow_request_session_key=True,
|
|
allowed_session_key_prefixes=["sess-"],
|
|
)
|
|
raw = {"id": "e1", "content": "hi", "session_key": "sess-abc"}
|
|
msg = HooksTranslator.translate(raw, mapping)
|
|
assert msg.metadata["session_key"] == "sess-abc"
|
|
|
|
def test_translate_session_key_prefix_not_allowed(self) -> None:
|
|
mapping = HookMapping(
|
|
match_path="/test",
|
|
allow_request_session_key=True,
|
|
allowed_session_key_prefixes=["valid-"],
|
|
default_session_key="default",
|
|
)
|
|
raw = {"id": "e1", "content": "hi", "session_key": "invalid-abc"}
|
|
msg = HooksTranslator.translate(raw, mapping)
|
|
assert msg.metadata["session_key"] == "default"
|
|
|
|
|
|
class TestHooksAdapter:
|
|
def test_channel_type(self) -> None:
|
|
adapter = HooksAdapter()
|
|
assert adapter.channel_type == ChannelType.HOOKS.value
|
|
|
|
def test_capabilities(self) -> None:
|
|
assert HOOKS_CAPABILITIES.media is False
|
|
assert HOOKS_CAPABILITIES.dm is True
|
|
assert HOOKS_CAPABILITIES.max_text_length == 32768
|
|
|
|
def test_get_default_config(self) -> None:
|
|
config = HooksAdapter.get_default_config()
|
|
assert "mappings" in config
|
|
|
|
def test_match_hook_exact(self) -> None:
|
|
adapter = HooksAdapter(
|
|
mappings=[
|
|
{"match_path": "/webhook", "match_source": "github"},
|
|
]
|
|
)
|
|
result = adapter.match_hook("/webhook", "github")
|
|
assert result is not None
|
|
assert result.match_path == "/webhook"
|
|
|
|
def test_match_hook_wildcard(self) -> None:
|
|
adapter = HooksAdapter(
|
|
mappings=[
|
|
{"match_path": "/hook1"},
|
|
]
|
|
)
|
|
result = adapter.match_hook("/hook1", "any-source")
|
|
assert result is not None
|
|
|
|
def test_match_hook_not_found(self) -> None:
|
|
adapter = HooksAdapter(
|
|
mappings=[
|
|
{"match_path": "/hook1"},
|
|
]
|
|
)
|
|
result = adapter.match_hook("/nonexistent")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_healthy_no_mappings(self) -> None:
|
|
adapter = HooksAdapter()
|
|
await adapter.open()
|
|
assert await adapter.is_healthy() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_is_healthy_with_mappings(self) -> None:
|
|
adapter = HooksAdapter(mappings=[{"match_path": "/h1"}])
|
|
await adapter.open()
|
|
assert await adapter.is_healthy() is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close(self) -> None:
|
|
adapter = HooksAdapter(mappings=[{"match_path": "/h1"}])
|
|
await adapter.open()
|
|
await adapter.close()
|
|
assert await adapter.is_healthy() is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_message(self) -> None:
|
|
adapter = HooksAdapter(mappings=[{"match_path": "/test"}])
|
|
raw = {"id": "e1", "content": "hi", "match_path": "/test"}
|
|
msg = await adapter.receive_message(raw)
|
|
assert msg.message_id == "e1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_receive_message_no_mapping_raises(self) -> None:
|
|
adapter = HooksAdapter(mappings=[{"match_path": "/other"}])
|
|
raw = {"id": "e1", "content": "hi", "match_path": "/nonexistent"}
|
|
with pytest.raises(ValueError, match="no hook mapping"):
|
|
await adapter.receive_message(raw)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_message_always_succeeds(self) -> None:
|
|
adapter = HooksAdapter()
|
|
result = await adapter.send_message("sess-1", "hello", channel_type="hooks", metadata={})
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_returns_true(self) -> None:
|
|
adapter = HooksAdapter()
|
|
result = await adapter.send_media("sess-1", url="http://x", media_type="image", metadata={})
|
|
assert result is True
|
|
|
|
def test_ws_connection_is_none(self) -> None:
|
|
adapter = HooksAdapter()
|
|
assert adapter.ws_connection is None
|
|
|
|
def test_route_contributor_has_router(self) -> None:
|
|
adapter = HooksAdapter()
|
|
contributor = adapter.route_contributor
|
|
assert contributor.router is not None
|