新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
410 lines
14 KiB
Python
410 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.mattermost.adapter import (
|
|
STREAM_BUFFER_MAX_CHARS,
|
|
MattermostAdapter,
|
|
_parse_driver_scheme_port,
|
|
)
|
|
from yuxi.channels.models import ChannelStatus
|
|
|
|
|
|
class TestParseDriverSchemePort:
|
|
def test_https_default(self):
|
|
scheme, port = _parse_driver_scheme_port("https://mattermost.example.com")
|
|
assert scheme == "https"
|
|
assert port == 443
|
|
|
|
def test_http_default(self):
|
|
scheme, port = _parse_driver_scheme_port("http://mattermost.example.com")
|
|
assert scheme == "http"
|
|
assert port == 80
|
|
|
|
def test_https_with_explicit_port(self):
|
|
scheme, port = _parse_driver_scheme_port("https://mattermost.example.com:8443")
|
|
assert scheme == "https"
|
|
assert port == 8443
|
|
|
|
def test_http_with_explicit_port(self):
|
|
scheme, port = _parse_driver_scheme_port("http://localhost:8065")
|
|
assert scheme == "http"
|
|
assert port == 8065
|
|
|
|
def test_no_scheme_defaults_to_https(self):
|
|
scheme, port = _parse_driver_scheme_port("mattermost.example.com")
|
|
assert scheme == "https"
|
|
assert port == 443
|
|
|
|
def test_url_with_path(self):
|
|
scheme, port = _parse_driver_scheme_port("https://mattermost.example.com/api/v4")
|
|
assert scheme == "https"
|
|
assert port == 443
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
config = {
|
|
"bot_token": "test-token",
|
|
"server_url": "https://mattermost.example.com",
|
|
}
|
|
return MattermostAdapter(config=config)
|
|
|
|
|
|
class TestShouldIgnorePost:
|
|
def test_ignore_bot_own_message(self, adapter):
|
|
adapter._bot_user_id = "bot_001"
|
|
post = {"user_id": "bot_001", "message": "hello"}
|
|
assert adapter._should_ignore_post(post) is True
|
|
|
|
def test_ignore_system_add_remove(self, adapter):
|
|
adapter._bot_user_id = "bot_001"
|
|
post = {"user_id": "u1", "type": "system_add_remove"}
|
|
assert adapter._should_ignore_post(post) is True
|
|
|
|
def test_ignore_system_join_leave(self, adapter):
|
|
adapter._bot_user_id = "bot_001"
|
|
post = {"user_id": "u1", "type": "system_join_leave"}
|
|
assert adapter._should_ignore_post(post) is True
|
|
|
|
def test_allow_other_user_message(self, adapter):
|
|
adapter._bot_user_id = "bot_001"
|
|
post = {"user_id": "u2", "message": "hello"}
|
|
assert adapter._should_ignore_post(post) is False
|
|
|
|
|
|
class TestParseWsPost:
|
|
def test_parse_json_string(self):
|
|
post = MattermostAdapter._parse_ws_post({"post": '{"id":"m1","message":"hello"}'})
|
|
assert post == {"id": "m1", "message": "hello"}
|
|
|
|
def test_parse_already_dict(self):
|
|
post_dict = {"id": "m1", "message": "hello"}
|
|
post = MattermostAdapter._parse_ws_post({"post": post_dict})
|
|
assert post == post_dict
|
|
|
|
def test_parse_invalid_json_returns_none(self):
|
|
post = MattermostAdapter._parse_ws_post({"post": "not-json"})
|
|
assert post is None
|
|
|
|
def test_parse_empty_string_returns_none(self):
|
|
post = MattermostAdapter._parse_ws_post({"post": ""})
|
|
assert post is None
|
|
|
|
def test_parse_missing_key_returns_none(self):
|
|
post = MattermostAdapter._parse_ws_post({})
|
|
assert post is None
|
|
|
|
def test_parse_none_value_returns_none(self):
|
|
post = MattermostAdapter._parse_ws_post({"post": None})
|
|
assert post is None
|
|
|
|
|
|
class TestSendStreamChunk:
|
|
def _make_adapter_for_stream(self):
|
|
config = {
|
|
"bot_token": "test-token",
|
|
"server_url": "https://mattermost.example.com",
|
|
}
|
|
adapter = MattermostAdapter(config=config)
|
|
adapter._bot_user_id = "bot_001"
|
|
adapter._bot_username = "testbot"
|
|
return adapter
|
|
|
|
def test_send_stream_chunk_not_connected(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "msg1", "hello", finished=False))
|
|
assert result.success is False
|
|
assert result.error == "Not connected"
|
|
|
|
def test_send_stream_chunk_buffer_accumulation(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "msg1", "chunk1", finished=False))
|
|
assert result.success is True
|
|
assert result.message_id == "msg1"
|
|
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "msg1", "chunk2", finished=False))
|
|
assert result.success is True
|
|
assert result.message_id == "msg1"
|
|
|
|
entry = adapter._streaming_messages.get("ch1", {})
|
|
pending = entry.get("_pending", "")
|
|
assert "chunk2" in pending
|
|
|
|
def test_send_stream_chunk_finished_flushes(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
asyncio.run(adapter.send_stream_chunk("ch1", "", "chunk1", finished=False))
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "", "chunk2", finished=True))
|
|
assert result.success is True
|
|
|
|
def test_send_stream_chunk_first_post_creates_msg(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "", "hello world", finished=False))
|
|
assert result.success is True
|
|
assert result.message_id == "mock_post_id"
|
|
|
|
def test_send_stream_chunk_with_existing_msg_id(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "existing_msg", "update text", finished=False))
|
|
assert result.success is True
|
|
assert result.message_id == "existing_msg"
|
|
|
|
def test_send_stream_chunk_buffer_overflow(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
huge_chunk = "x" * (STREAM_BUFFER_MAX_CHARS + 100)
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "", huge_chunk, finished=False))
|
|
assert result.success is True
|
|
|
|
def test_send_stream_chunk_concurrent_lock(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
async def send_chunks():
|
|
tasks = []
|
|
for i in range(10):
|
|
tasks.append(adapter.send_stream_chunk("ch1", "", f"chunk_{i}", finished=False))
|
|
results = await asyncio.gather(*tasks)
|
|
return results
|
|
|
|
results = asyncio.run(send_chunks())
|
|
assert all(r.success for r in results)
|
|
|
|
def test_send_stream_chunk_cleanup_on_finished(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
asyncio.run(adapter.send_stream_chunk("ch1", "msg_xxx", "final", finished=True))
|
|
assert "ch1" not in adapter._streaming_messages
|
|
|
|
def test_send_stream_chunk_rate_limiting(self):
|
|
adapter = self._make_adapter_for_stream()
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._driver = _make_mock_driver()
|
|
|
|
result = asyncio.run(adapter.send_stream_chunk("ch1", "", "first", finished=False))
|
|
assert result.success is True
|
|
|
|
entry = adapter._streaming_messages.get("ch1", {})
|
|
assert "_last_update" in entry
|
|
|
|
|
|
class TestSendMedia:
|
|
def _make_adapter(self):
|
|
config = {
|
|
"bot_token": "test-token",
|
|
"server_url": "https://mattermost.example.com",
|
|
}
|
|
adapter = MattermostAdapter(config=config)
|
|
adapter._bot_user_id = "bot_001"
|
|
return adapter
|
|
|
|
def test_send_media_not_connected(self):
|
|
adapter = self._make_adapter()
|
|
result = asyncio.run(adapter.send_media("ch1", "image", b"fake_data"))
|
|
assert result.success is False
|
|
assert result.error == "Not connected"
|
|
|
|
|
|
class TestDownloadMedia:
|
|
def _make_adapter(self):
|
|
config = {
|
|
"bot_token": "test-token",
|
|
"server_url": "https://mattermost.example.com",
|
|
}
|
|
adapter = MattermostAdapter(config=config)
|
|
adapter._bot_user_id = "bot_001"
|
|
return adapter
|
|
|
|
def test_download_media_not_connected(self):
|
|
adapter = self._make_adapter()
|
|
with pytest.raises(Exception):
|
|
asyncio.run(adapter.download_media("file_001"))
|
|
|
|
|
|
class TestRefreshToken:
|
|
def test_refresh_token_returns_true(self, adapter):
|
|
result = asyncio.run(adapter._refresh_token_if_needed())
|
|
assert result is True
|
|
|
|
|
|
class TestReconnectTaskTracking:
|
|
def test_ws_reconnect_task_initialized_none(self, adapter):
|
|
assert adapter._ws_reconnect_task is None
|
|
|
|
def test_streaming_lock_initialized(self, adapter):
|
|
assert adapter._streaming_lock is not None
|
|
|
|
|
|
class TestDedupCheck:
|
|
def test_first_post_not_duplicate(self, adapter):
|
|
assert adapter._dedup_check("post_001") is False
|
|
|
|
def test_second_same_post_is_duplicate(self, adapter):
|
|
assert adapter._dedup_check("post_001") is False
|
|
assert adapter._dedup_check("post_001") is True
|
|
|
|
def test_different_posts_not_duplicates(self, adapter):
|
|
assert adapter._dedup_check("post_001") is False
|
|
assert adapter._dedup_check("post_002") is False
|
|
|
|
def test_empty_post_id_not_duplicate(self, adapter):
|
|
assert adapter._dedup_check("") is False
|
|
assert adapter._dedup_check("") is False
|
|
|
|
def test_cache_eviction_on_overflow(self, adapter):
|
|
adapter._seen_posts = {f"post_{i}": 0.0 for i in range(2050)}
|
|
result = adapter._dedup_check("post_new")
|
|
assert result is False
|
|
assert len(adapter._seen_posts) <= 2050
|
|
|
|
|
|
class TestNormalizeReaction:
|
|
def _make_adapter(self):
|
|
from yuxi.channels.adapters.mattermost.adapter import MattermostAdapter
|
|
|
|
config = {
|
|
"bot_token": "test-token",
|
|
"server_url": "https://mattermost.example.com",
|
|
}
|
|
adapter = MattermostAdapter(config=config)
|
|
adapter._bot_user_id = "bot_001"
|
|
adapter._bot_username = "testbot"
|
|
return adapter
|
|
|
|
def test_reaction_added_normalization(self):
|
|
adapter = self._make_adapter()
|
|
raw = {
|
|
"event": "reaction_added",
|
|
"data": {"reaction": '{"user_id":"u1","post_id":"p1","emoji_name":"+1","create_at":123}'},
|
|
"broadcast": {"channel_id": "ch1", "team_id": "t1", "user_id": "u1"},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type == "system.event"
|
|
assert msg.content == ":+1:"
|
|
assert msg.metadata["reaction_event"] == "reaction_added"
|
|
assert msg.metadata["emoji_name"] == "+1"
|
|
assert msg.metadata["post_id"] == "p1"
|
|
assert msg.metadata["channel_id"] == "ch1"
|
|
assert msg.identity.channel_user_id == "u1"
|
|
|
|
def test_reaction_removed_normalization(self):
|
|
adapter = self._make_adapter()
|
|
raw = {
|
|
"event": "reaction_removed",
|
|
"data": {"reaction": '{"user_id":"u2","post_id":"p2","emoji_name":"thumbsup"}'},
|
|
"broadcast": {"channel_id": "ch2", "team_id": "t2", "user_id": "u2"},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.event_type == "system.event"
|
|
assert msg.content == ":thumbsup:"
|
|
assert msg.metadata["reaction_event"] == "reaction_removed"
|
|
|
|
def test_reaction_data_already_dict(self):
|
|
adapter = self._make_adapter()
|
|
raw = {
|
|
"event": "reaction_added",
|
|
"data": {"reaction": {"user_id": "u3", "post_id": "p3", "emoji_name": "heart"}},
|
|
"broadcast": {"channel_id": "ch3", "team_id": "t3", "user_id": "u3"},
|
|
}
|
|
msg = adapter.normalize_inbound(raw)
|
|
assert msg.content == ":heart:"
|
|
|
|
|
|
class TestErrorLogging:
|
|
def _make_adapter(self):
|
|
config = {
|
|
"bot_token": "test-token",
|
|
"server_url": "https://mattermost.example.com",
|
|
}
|
|
adapter = MattermostAdapter(config=config)
|
|
adapter._bot_user_id = "bot_001"
|
|
adapter._bot_username = "testbot"
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
|
|
class FailingPosts:
|
|
def update_post(self, **kwargs):
|
|
raise RuntimeError("connection refused")
|
|
|
|
def delete_post(self, **kwargs):
|
|
raise RuntimeError("connection refused")
|
|
|
|
def create_post(self, **kwargs):
|
|
raise RuntimeError("connection refused")
|
|
|
|
class FailingReactions:
|
|
def create_reaction(self, **kwargs):
|
|
raise RuntimeError("connection refused")
|
|
|
|
class FailingFiles:
|
|
def upload_file(self, **kwargs):
|
|
raise RuntimeError("connection refused")
|
|
|
|
class FailingDriver:
|
|
def __init__(self):
|
|
self.posts = FailingPosts()
|
|
self.reactions = FailingReactions()
|
|
self.files = FailingFiles()
|
|
|
|
adapter._driver = FailingDriver()
|
|
adapter._ws_task = object()
|
|
return adapter
|
|
|
|
def test_edit_message_returns_failure_on_error(self):
|
|
adapter = self._make_adapter()
|
|
result = asyncio.run(adapter.edit_message("ch1", "msg1", "test"))
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
|
|
def test_delete_message_returns_failure_on_error(self):
|
|
adapter = self._make_adapter()
|
|
result = asyncio.run(adapter.delete_message("ch1", "msg1"))
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
|
|
def test_send_reaction_returns_failure_on_error(self):
|
|
adapter = self._make_adapter()
|
|
result = asyncio.run(adapter.send_reaction("ch1", "msg1", "+1"))
|
|
assert result.success is False
|
|
assert result.error is not None
|
|
|
|
def test_send_media_raises_on_error(self):
|
|
from yuxi.channels.exceptions import DeliveryFailedError
|
|
|
|
adapter = self._make_adapter()
|
|
with pytest.raises(DeliveryFailedError):
|
|
asyncio.run(adapter.send_media("ch1", "image", b"data"))
|
|
|
|
|
|
def _make_mock_driver():
|
|
class MockPosts:
|
|
def create_post(self, options=None):
|
|
return {"id": "mock_post_id"}
|
|
|
|
def patch_post(self, post_id=None, options=None):
|
|
return {"id": post_id}
|
|
|
|
class MockDriver:
|
|
def __init__(self):
|
|
self.posts = MockPosts()
|
|
|
|
return MockDriver()
|