ForcePilot/backend/test/unit/channels/test_mattermost_adapter_fixes.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

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()