390 lines
14 KiB
Python
390 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from yuxi.channel.plugins.builders import _BaseChannelPlugin
|
|
from yuxi.channel.plugins.protocol import (
|
|
ChannelHealthStatus,
|
|
ChannelMeta,
|
|
ChannelPlugin,
|
|
DeliveryCapabilities,
|
|
InboundMedia,
|
|
InboundMessage,
|
|
InboundRequest,
|
|
OutboundMessage,
|
|
QRLoginSession,
|
|
SessionConversationRef,
|
|
Transport,
|
|
)
|
|
from yuxi.channel.ports import (
|
|
AuthPort,
|
|
BindingPort,
|
|
ConfigPort,
|
|
InboundPort,
|
|
LifecyclePort,
|
|
MetaPort,
|
|
OutboundPort,
|
|
SecurityPort,
|
|
SessionPort,
|
|
StatusPort,
|
|
ThreadingPort,
|
|
TransportPort,
|
|
)
|
|
from yuxi.channel.security.models import DmPolicy, GroupPolicy, RateLimitPolicy
|
|
|
|
|
|
class TestPortsAreRuntimeCheckable:
|
|
def test_meta_port_is_runtime_checkable(self):
|
|
assert getattr(MetaPort, "_is_runtime_checkable", False)
|
|
|
|
def test_config_port_is_runtime_checkable(self):
|
|
assert getattr(ConfigPort, "_is_runtime_checkable", False)
|
|
|
|
def test_inbound_port_is_runtime_checkable(self):
|
|
assert getattr(InboundPort, "_is_runtime_checkable", False)
|
|
|
|
def test_outbound_port_is_runtime_checkable(self):
|
|
assert getattr(OutboundPort, "_is_runtime_checkable", False)
|
|
|
|
def test_transport_port_is_runtime_checkable(self):
|
|
assert getattr(TransportPort, "_is_runtime_checkable", False)
|
|
|
|
def test_security_port_is_runtime_checkable(self):
|
|
assert getattr(SecurityPort, "_is_runtime_checkable", False)
|
|
|
|
def test_status_port_is_runtime_checkable(self):
|
|
assert getattr(StatusPort, "_is_runtime_checkable", False)
|
|
|
|
def test_lifecycle_port_is_runtime_checkable(self):
|
|
assert getattr(LifecyclePort, "_is_runtime_checkable", False)
|
|
|
|
def test_session_port_is_runtime_checkable(self):
|
|
assert getattr(SessionPort, "_is_runtime_checkable", False)
|
|
|
|
def test_auth_port_is_runtime_checkable(self):
|
|
assert getattr(AuthPort, "_is_runtime_checkable", False)
|
|
|
|
def test_binding_port_is_runtime_checkable(self):
|
|
assert getattr(BindingPort, "_is_runtime_checkable", False)
|
|
|
|
def test_threading_port_is_runtime_checkable(self):
|
|
assert getattr(ThreadingPort, "_is_runtime_checkable", False)
|
|
|
|
|
|
class TestMinimalPortImplementations:
|
|
def test_minimal_meta_port_implementation(self):
|
|
class MinimalMeta:
|
|
def get_meta(self) -> ChannelMeta:
|
|
return ChannelMeta(channel_type="test", display_name="Test")
|
|
|
|
assert isinstance(MinimalMeta(), MetaPort)
|
|
|
|
def test_minimal_config_port_implementation(self):
|
|
class MinimalConfig:
|
|
def validate_config(self, config: dict) -> tuple[bool, list[str]]:
|
|
return True, []
|
|
|
|
def list_account_ids(self, config: dict) -> list[str]:
|
|
return []
|
|
|
|
def resolve_account(self, config: dict, account_id: str | None = None) -> dict:
|
|
return config
|
|
|
|
assert isinstance(MinimalConfig(), ConfigPort)
|
|
|
|
def test_minimal_inbound_port_implementation(self):
|
|
class MinimalInbound:
|
|
async def validate_webhook(self, request: InboundRequest) -> bool:
|
|
return True
|
|
|
|
async def preprocess_event(
|
|
self, request: InboundRequest, config: dict, account_id: str
|
|
) -> tuple[bool, dict[str, Any] | None, dict | None]:
|
|
return True, None, {}
|
|
|
|
async def normalize_inbound(self, request: InboundRequest) -> InboundMessage:
|
|
return InboundMessage(channel_type="test", account_id="acc")
|
|
|
|
async def build_webhook_response(
|
|
self, request: InboundRequest, inbound: InboundMessage
|
|
) -> dict[str, Any] | None:
|
|
return None
|
|
|
|
async def resolve_account_id_from_webhook(self, request: InboundRequest) -> str | None:
|
|
return None
|
|
|
|
assert isinstance(MinimalInbound(), InboundPort)
|
|
|
|
def test_minimal_outbound_port_implementation(self):
|
|
class MinimalOutbound:
|
|
async def format_outbound(self, message: OutboundMessage, config: dict | None = None) -> dict:
|
|
return {}
|
|
|
|
async def send_message(
|
|
self, channel_session_id: str, payload: dict, config: dict | None = None
|
|
) -> str | None:
|
|
return None
|
|
|
|
def chunk_text(self, text: str, limit: int) -> list[str]:
|
|
return []
|
|
|
|
def get_delivery_capabilities(self) -> DeliveryCapabilities:
|
|
return DeliveryCapabilities()
|
|
|
|
async def enrich_outbound(
|
|
self, payload: dict, message: OutboundMessage, config: dict | None = None
|
|
) -> dict:
|
|
return payload
|
|
|
|
async def upload_media(self, media: InboundMedia | dict, config: dict | None = None) -> dict | None:
|
|
return None
|
|
|
|
def supports_batch_send(self, config: dict | None = None) -> bool:
|
|
return False
|
|
|
|
async def send_batch(
|
|
self, channel_session_id: str, payloads: list[dict], config: dict | None = None
|
|
) -> list[str | None]:
|
|
return []
|
|
|
|
async def send_typing_indicator(self, channel_session_id: str, config: dict | None = None) -> None:
|
|
return None
|
|
|
|
def supports_content_type(self, content_type: str, config: dict | None = None) -> bool:
|
|
return False
|
|
|
|
assert isinstance(MinimalOutbound(), OutboundPort)
|
|
|
|
def test_minimal_transport_port_implementation(self):
|
|
class MinimalTransport:
|
|
async def create_transport(
|
|
self, config: dict, account_id: str, credentials: dict | None = None
|
|
) -> Transport | None:
|
|
return None
|
|
|
|
async def on_transport_message(self, raw: bytes, config: dict, account_id: str) -> InboundMessage | None:
|
|
return None
|
|
|
|
assert isinstance(MinimalTransport(), TransportPort)
|
|
|
|
def test_minimal_security_port_implementation(self):
|
|
class MinimalSecurity:
|
|
def resolve_dm_policy(self, config: dict, account_id: str | None = None) -> DmPolicy | None:
|
|
return None
|
|
|
|
def resolve_group_policy(self, config: dict, account_id: str | None = None) -> GroupPolicy | None:
|
|
return None
|
|
|
|
def resolve_rate_limit_policy(self, config: dict, account_id: str | None = None) -> RateLimitPolicy | None:
|
|
return None
|
|
|
|
assert isinstance(MinimalSecurity(), SecurityPort)
|
|
|
|
def test_minimal_status_port_implementation(self):
|
|
class MinimalStatus:
|
|
async def health_check(self, config: dict, account_id: str) -> ChannelHealthStatus:
|
|
return ChannelHealthStatus(healthy=True, state="ok", enabled=True)
|
|
|
|
assert isinstance(MinimalStatus(), StatusPort)
|
|
|
|
def test_minimal_lifecycle_port_implementation(self):
|
|
class MinimalLifecycle:
|
|
async def on_config_changed(self, config: dict, account_id: str) -> None:
|
|
return None
|
|
|
|
async def on_account_removed(self, account_id: str) -> None:
|
|
return None
|
|
|
|
async def on_channel_enabled(self, config: dict, account_id: str) -> None:
|
|
return None
|
|
|
|
async def on_channel_disabled(self, config: dict, account_id: str) -> None:
|
|
return None
|
|
|
|
async def run_startup_maintenance(self, config: dict, account_id: str) -> None:
|
|
return None
|
|
|
|
assert isinstance(MinimalLifecycle(), LifecyclePort)
|
|
|
|
def test_minimal_session_port_implementation(self):
|
|
class MinimalSession:
|
|
def resolve_session_conversation(self, chat_type: str, raw_id: str) -> SessionConversationRef | None:
|
|
return None
|
|
|
|
def parse_session_key(self, inbound: InboundMessage) -> str:
|
|
return "key"
|
|
|
|
assert isinstance(MinimalSession(), SessionPort)
|
|
|
|
def test_minimal_auth_port_implementation(self):
|
|
class MinimalAuth:
|
|
def supports_qr_login(self, config: dict | None = None) -> bool:
|
|
return False
|
|
|
|
async def create_qr_login_session(self, config: dict, account_id: str) -> QRLoginSession:
|
|
return QRLoginSession(session_id="s")
|
|
|
|
async def poll_qr_login_status(
|
|
self, session: QRLoginSession, config: dict, account_id: str
|
|
) -> QRLoginSession:
|
|
return session
|
|
|
|
async def load_qr_credentials(self, config: dict, account_id: str) -> QRLoginSession | None:
|
|
return None
|
|
|
|
async def logout_qr_login(self, config: dict, account_id: str) -> bool:
|
|
return False
|
|
|
|
assert isinstance(MinimalAuth(), AuthPort)
|
|
|
|
def test_minimal_binding_port_implementation(self):
|
|
class MinimalBinding:
|
|
def compile_binding(self, config: dict, inbound: InboundMessage) -> Any | None:
|
|
return None
|
|
|
|
def match_binding(self, config: dict, binding: Any, inbound: InboundMessage) -> bool:
|
|
return False
|
|
|
|
assert isinstance(MinimalBinding(), BindingPort)
|
|
|
|
def test_minimal_threading_port_implementation(self):
|
|
class MinimalThreading:
|
|
def resolve_reply_to_mode(self, config: dict, inbound: InboundMessage) -> str | None:
|
|
return None
|
|
|
|
def resolve_auto_thread_id(self, config: dict, inbound: InboundMessage) -> str | None:
|
|
return None
|
|
|
|
assert isinstance(MinimalThreading(), ThreadingPort)
|
|
|
|
|
|
class TestBaseChannelPluginPortCompliance:
|
|
@pytest.fixture
|
|
def plugin(self):
|
|
meta = ChannelMeta(channel_type="test", display_name="Test")
|
|
return _BaseChannelPlugin(meta=meta, adapters={})
|
|
|
|
def test_is_channel_plugin(self, plugin):
|
|
assert isinstance(plugin, ChannelPlugin)
|
|
|
|
def test_implements_meta_port(self, plugin):
|
|
assert isinstance(plugin, MetaPort)
|
|
|
|
def test_implements_config_port(self, plugin):
|
|
assert isinstance(plugin, ConfigPort)
|
|
|
|
def test_implements_inbound_port(self, plugin):
|
|
assert isinstance(plugin, InboundPort)
|
|
|
|
def test_implements_outbound_port(self, plugin):
|
|
assert isinstance(plugin, OutboundPort)
|
|
|
|
def test_implements_transport_port(self, plugin):
|
|
assert isinstance(plugin, TransportPort)
|
|
|
|
def test_implements_security_port(self, plugin):
|
|
assert isinstance(plugin, SecurityPort)
|
|
|
|
def test_implements_status_port(self, plugin):
|
|
assert isinstance(plugin, StatusPort)
|
|
|
|
def test_implements_lifecycle_port(self, plugin):
|
|
assert isinstance(plugin, LifecyclePort)
|
|
|
|
def test_implements_session_port(self, plugin):
|
|
assert isinstance(plugin, SessionPort)
|
|
|
|
def test_implements_auth_port(self, plugin):
|
|
assert isinstance(plugin, AuthPort)
|
|
|
|
def test_implements_binding_port(self, plugin):
|
|
assert isinstance(plugin, BindingPort)
|
|
|
|
def test_implements_threading_port(self, plugin):
|
|
assert isinstance(plugin, ThreadingPort)
|
|
|
|
|
|
class TestMetaMixin:
|
|
def test_init_stores_meta(self):
|
|
from yuxi.channel.ports import MetaMixin
|
|
|
|
meta = ChannelMeta(channel_type="test", display_name="Test")
|
|
mixin = MetaMixin(meta)
|
|
assert mixin.get_meta() is meta
|
|
|
|
|
|
class TestOutboundMixin:
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
from yuxi.channel.ports import OutboundMixin
|
|
|
|
return OutboundMixin()
|
|
|
|
async def test_format_outbound_returns_content(self, mixin):
|
|
msg = OutboundMessage(content="hello")
|
|
result = await mixin.format_outbound(msg)
|
|
assert result == {"content": "hello"}
|
|
|
|
def test_get_delivery_capabilities_defaults(self, mixin):
|
|
caps = mixin.get_delivery_capabilities()
|
|
assert isinstance(caps, DeliveryCapabilities)
|
|
assert caps.max_text_length == 4000
|
|
assert caps.supports_markdown is False
|
|
assert caps.supports_interactive is False
|
|
assert caps.supports_media is False
|
|
|
|
def test_chunk_text_short(self, mixin):
|
|
assert mixin.chunk_text("hello", 10) == ["hello"]
|
|
|
|
def test_chunk_text_splits(self, mixin):
|
|
assert mixin.chunk_text("abcdef", 2) == ["ab", "cd", "ef"]
|
|
|
|
def test_supports_content_type_text(self, mixin):
|
|
assert mixin.supports_content_type("text") is True
|
|
|
|
def test_supports_content_type_defaults(self, mixin):
|
|
assert mixin.supports_content_type("markdown") is False
|
|
assert mixin.supports_content_type("image") is False
|
|
assert mixin.supports_content_type("interactive") is False
|
|
|
|
|
|
class TestSecurityMixin:
|
|
@pytest.fixture
|
|
def mixin(self):
|
|
from yuxi.channel.ports import SecurityMixin
|
|
|
|
return SecurityMixin()
|
|
|
|
def test_resolve_dm_policy(self, mixin):
|
|
policy = mixin.resolve_dm_policy(
|
|
{"dm_policy": {"mode": "allow_from", "allow_list": ["u1"], "deny_list": ["u2"]}}
|
|
)
|
|
assert policy.mode == "allow_from"
|
|
assert policy.allow_list == ["u1"]
|
|
assert policy.deny_list == ["u2"]
|
|
|
|
def test_resolve_dm_policy_defaults(self, mixin):
|
|
policy = mixin.resolve_dm_policy({})
|
|
assert policy.mode == "open"
|
|
assert policy.allow_list == []
|
|
assert policy.deny_list == []
|
|
|
|
def test_resolve_group_policy(self, mixin):
|
|
policy = mixin.resolve_group_policy(
|
|
{"group_policy": {"require_mention": True, "allow_groups": ["g1"], "deny_groups": ["g2"]}}
|
|
)
|
|
assert policy.require_mention is True
|
|
assert policy.allow_groups == ["g1"]
|
|
assert policy.deny_groups == ["g2"]
|
|
|
|
def test_resolve_rate_limit_policy(self, mixin):
|
|
policy = mixin.resolve_rate_limit_policy(
|
|
{"rate_limit": {"max_requests_per_minute": 120, "max_concurrent_agent_runs": 10}}
|
|
)
|
|
assert policy.max_requests_per_minute == 120
|
|
assert policy.max_concurrent_agent_runs == 10
|
|
|
|
def test_resolve_rate_limit_policy_returns_none_when_missing(self, mixin):
|
|
assert mixin.resolve_rate_limit_policy({}) is None
|