1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
1030 lines
37 KiB
Python
1030 lines
37 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
from yuxi.channels.adapters.slack.adapter import SlackAdapter
|
|
from yuxi.channels.models import (
|
|
Attachment,
|
|
ChannelIdentity,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
EventType,
|
|
MentionsInfo,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter():
|
|
return SlackAdapter({"bot_token": "xoxb-test-token", "app_token": "xapp-test-token"})
|
|
|
|
|
|
@pytest.fixture
|
|
def adapter_with_bot(adapter):
|
|
adapter._bot_user_id = "BOT123"
|
|
adapter._bot_id = "B456"
|
|
return adapter
|
|
|
|
|
|
def _sample_msg_event(**overrides):
|
|
data = {
|
|
"event": {
|
|
"type": "message",
|
|
"channel": "D1234567",
|
|
"user": "U001",
|
|
"ts": "1234567890.123456",
|
|
"text": "Hello",
|
|
},
|
|
"event_type": "message",
|
|
"team_id": "T001",
|
|
}
|
|
data["event"].update(overrides.get("event", {}))
|
|
data.update({k: v for k, v in overrides.items() if k != "event"})
|
|
return data
|
|
|
|
|
|
class TestNormalizeInbound:
|
|
def test_dm_message(self, adapter_with_bot):
|
|
result = adapter_with_bot.normalize_inbound(_sample_msg_event())
|
|
|
|
assert result.identity.channel_id == "slack"
|
|
assert result.identity.channel_type == ChannelType.SLACK
|
|
assert result.identity.channel_user_id == "U001"
|
|
assert result.identity.channel_chat_id == "dm_U001"
|
|
assert result.identity.channel_message_id == "D1234567:1234567890.123456"
|
|
assert result.event_type == EventType.MESSAGE_RECEIVED
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.chat_type == ChatType.DIRECT
|
|
assert result.content == "Hello"
|
|
assert result.mentions.mentioned_user_ids == []
|
|
assert result.mentions.is_bot_mentioned is False
|
|
|
|
def test_group_message(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "C1234567", "channel_type": "channel"})
|
|
result = adapter.normalize_inbound(raw)
|
|
|
|
assert result.identity.channel_chat_id == "channel_C1234567"
|
|
assert result.chat_type == ChatType.CHANNEL
|
|
|
|
def test_thread_message(self, adapter):
|
|
raw = _sample_msg_event(
|
|
event={
|
|
"channel": "C1234567",
|
|
"thread_ts": "100.200",
|
|
"ts": "200.300",
|
|
}
|
|
)
|
|
result = adapter.normalize_inbound(raw)
|
|
|
|
assert result.chat_type == ChatType.THREAD
|
|
assert result.reply_to_message_id == "100.200"
|
|
assert result.metadata["thread_ts"] == "100.200"
|
|
|
|
def test_bot_mention(self, adapter_with_bot):
|
|
raw = _sample_msg_event(
|
|
event={"channel": "C1234567", "text": f"Hey <@{adapter_with_bot._bot_user_id}> help me"}
|
|
)
|
|
result = adapter_with_bot.normalize_inbound(raw)
|
|
|
|
assert result.mentions.is_bot_mentioned is True
|
|
assert adapter_with_bot._bot_user_id in result.mentions.mentioned_user_ids
|
|
|
|
def test_extract_mentions(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "D1234567", "text": "@U002 @U003 你们好 <@U002> <@U003>"})
|
|
result = adapter.normalize_inbound(raw)
|
|
|
|
assert "U002" in result.mentions.mentioned_user_ids
|
|
assert "U003" in result.mentions.mentioned_user_ids
|
|
|
|
def test_no_text_no_mentions(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "C1234567", "text": ""})
|
|
result = adapter.normalize_inbound(raw)
|
|
|
|
assert result.mentions.mentioned_user_ids == []
|
|
assert result.mentions.is_bot_mentioned is False
|
|
|
|
def test_image_message(self, adapter):
|
|
raw = _sample_msg_event(
|
|
event={
|
|
"channel": "D1234567",
|
|
"text": "",
|
|
"files": [{"mimetype": "image/png", "url_private": "https://example.com/img.png"}],
|
|
}
|
|
)
|
|
result = adapter.normalize_inbound(raw)
|
|
|
|
assert result.message_type == MessageType.IMAGE
|
|
assert len(result.attachments) == 1
|
|
assert result.attachments[0].type == "image"
|
|
assert result.attachments[0].mime_type == "image/png"
|
|
|
|
def test_video_message(self, adapter):
|
|
raw = _sample_msg_event(
|
|
event={
|
|
"channel": "D1234567",
|
|
"text": "",
|
|
"files": [{"mimetype": "video/mp4", "url_private": "https://example.com/video.mp4"}],
|
|
}
|
|
)
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.message_type == MessageType.VIDEO
|
|
assert result.attachments[0].type == "video"
|
|
|
|
def test_audio_message(self, adapter):
|
|
raw = _sample_msg_event(
|
|
event={
|
|
"channel": "D1234567",
|
|
"text": "",
|
|
"files": [{"mimetype": "audio/mp3", "url_private": "https://example.com/audio.mp3"}],
|
|
}
|
|
)
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.message_type == MessageType.AUDIO
|
|
assert result.attachments[0].type == "audio"
|
|
|
|
def test_generic_file_message(self, adapter):
|
|
raw = _sample_msg_event(
|
|
event={
|
|
"channel": "D1234567",
|
|
"text": "",
|
|
"files": [
|
|
{
|
|
"mimetype": "application/pdf",
|
|
"url_private": "https://example.com/doc.pdf",
|
|
"name": "doc.pdf",
|
|
"size": 1024,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.message_type == MessageType.FILE
|
|
assert result.attachments[0].type == "file"
|
|
assert result.attachments[0].filename == "doc.pdf"
|
|
assert result.attachments[0].size_bytes == 1024
|
|
|
|
def test_no_files_field(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "D1234567", "text": "plain text"})
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.message_type == MessageType.TEXT
|
|
assert result.attachments == []
|
|
|
|
def test_empty_files_list(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "D1234567", "text": "text", "files": []})
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.message_type == MessageType.TEXT
|
|
|
|
def test_interactive_event(self, adapter):
|
|
raw = {
|
|
"event": {
|
|
"type": "interactive",
|
|
"user": "U001",
|
|
"channel": "C1234567",
|
|
"ts": "123.456",
|
|
},
|
|
"event_type": "interactive",
|
|
"team_id": "T001",
|
|
"interaction": {"type": "block_actions", "actions": [{"action_id": "btn_1"}]},
|
|
}
|
|
result = adapter.normalize_inbound(raw)
|
|
|
|
assert result.event_type == EventType.CARD_ACTION
|
|
assert result.metadata["interaction_type"] == "block_actions"
|
|
assert "interaction_payload" in result.metadata
|
|
|
|
def test_empty_content_without_text(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "C1234567", "text": ""})
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.content == ""
|
|
|
|
def test_metadata_contains_team_id(self, adapter):
|
|
raw = _sample_msg_event()
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.metadata["team_id"] == "T001"
|
|
|
|
def test_metadata_channels(self, adapter):
|
|
raw = _sample_msg_event(event={"channel": "C1234567", "ts": "111.222"})
|
|
result = adapter.normalize_inbound(raw)
|
|
assert result.metadata["channel"] == "C1234567"
|
|
assert result.metadata["ts"] == "111.222"
|
|
|
|
|
|
class TestFormatOutbound:
|
|
def test_text_output(self, adapter):
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C1234567",
|
|
),
|
|
content="Hello World",
|
|
)
|
|
payload = adapter.format_outbound(resp)
|
|
|
|
assert payload["text"] == "Hello World"
|
|
assert payload["mrkdwn"] is True
|
|
|
|
def test_with_thread_ts(self, adapter):
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C1234567",
|
|
),
|
|
content="Reply",
|
|
metadata={"thread_ts": "100.200"},
|
|
)
|
|
payload = adapter.format_outbound(resp)
|
|
assert payload["thread_ts"] == "100.200"
|
|
|
|
def test_reply_to_message_id(self, adapter):
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C1234567",
|
|
),
|
|
content="Reply",
|
|
reply_to_message_id="100.300",
|
|
)
|
|
payload = adapter.format_outbound(resp)
|
|
assert payload["thread_ts"] == "100.300"
|
|
|
|
def test_with_blocks(self, adapter):
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C1234567",
|
|
),
|
|
content="With blocks",
|
|
metadata={"blocks": [{"type": "section", "text": {"type": "mrkdwn", "text": "Block content"}}]},
|
|
)
|
|
payload = adapter.format_outbound(resp)
|
|
assert "blocks" in payload
|
|
assert len(payload["blocks"]) == 1
|
|
|
|
def test_with_attachments(self, adapter):
|
|
from yuxi.channels.models import Attachment, ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C1234567",
|
|
),
|
|
content="File attachment",
|
|
attachments=[Attachment(type="file", url="https://example.com/file.pdf", filename="file.pdf")],
|
|
)
|
|
payload = adapter.format_outbound(resp)
|
|
assert "attachments" in payload
|
|
assert len(payload["attachments"]) == 1
|
|
assert payload["attachments"][0]["url"] == "https://example.com/file.pdf"
|
|
|
|
|
|
class TestHealthCheck:
|
|
@pytest.mark.asyncio
|
|
async def test_unhealthy_when_disconnected(self, adapter):
|
|
result = await adapter.health_check()
|
|
assert result.status == "unhealthy"
|
|
assert result.last_error == "Socket Mode not connected"
|
|
|
|
|
|
class TestTokenResolution:
|
|
def test_config_token(self):
|
|
adapter = SlackAdapter({"bot_token": "xoxb-from-config", "app_token": "xapp-from-config"})
|
|
assert adapter._resolve_bot_token() == "xoxb-from-config"
|
|
assert adapter._resolve_app_token() == "xapp-from-config"
|
|
|
|
def test_env_fallback(self, monkeypatch):
|
|
monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-from-env")
|
|
monkeypatch.setenv("SLACK_APP_TOKEN", "xapp-from-env")
|
|
adapter = SlackAdapter({})
|
|
assert adapter._resolve_bot_token() == "xoxb-from-env"
|
|
assert adapter._resolve_app_token() == "xapp-from-env"
|
|
|
|
|
|
class TestFileMessageType:
|
|
def test_image_type(self, adapter):
|
|
assert adapter._resolve_file_message_type([{"mimetype": "image/png"}]) == MessageType.IMAGE
|
|
|
|
def test_video_type(self, adapter):
|
|
assert adapter._resolve_file_message_type([{"mimetype": "video/mp4"}]) == MessageType.VIDEO
|
|
|
|
def test_audio_type(self, adapter):
|
|
assert adapter._resolve_file_message_type([{"mimetype": "audio/mp3"}]) == MessageType.AUDIO
|
|
|
|
def test_unknown_type(self, adapter):
|
|
assert adapter._resolve_file_message_type([{"mimetype": "application/pdf"}]) == MessageType.FILE
|
|
|
|
def test_empty_list(self, adapter):
|
|
assert adapter._resolve_file_message_type([]) == MessageType.TEXT
|
|
|
|
|
|
class TestExtractAttachments:
|
|
def test_image_attachment(self, adapter):
|
|
files = [
|
|
{"mimetype": "image/png", "url_private": "https://example.com/img.png", "name": "img.png", "size": 2048}
|
|
]
|
|
atts = adapter._extract_attachments(files)
|
|
assert len(atts) == 1
|
|
assert atts[0].type == "image"
|
|
assert atts[0].mime_type == "image/png"
|
|
assert atts[0].filename == "img.png"
|
|
assert atts[0].size_bytes == 2048
|
|
|
|
def test_multiple_files(self, adapter):
|
|
files = [
|
|
{"mimetype": "image/png", "url_private": "https://example.com/img1.png"},
|
|
{"mimetype": "application/pdf", "url_private": "https://example.com/doc.pdf"},
|
|
]
|
|
atts = adapter._extract_attachments(files)
|
|
assert len(atts) == 2
|
|
assert atts[0].type == "image"
|
|
assert atts[1].type == "file"
|
|
|
|
|
|
class TestMentionIds:
|
|
def test_basic_mentions(self, adapter):
|
|
ids = adapter._extract_mention_ids("Hello <@U001> and <@U002>")
|
|
assert ids == ["U001", "U002"]
|
|
|
|
def test_no_mentions(self, adapter):
|
|
ids = adapter._extract_mention_ids("Hello World")
|
|
assert ids == []
|
|
|
|
def test_empty_text(self, adapter):
|
|
ids = adapter._extract_mention_ids("")
|
|
assert ids == []
|
|
|
|
def test_bot_mention(self, adapter):
|
|
adapter._bot_user_id = "BOT001"
|
|
assert adapter._check_bot_mention("Hey <@BOT001> help") is True
|
|
assert adapter._check_bot_mention("Hey <@U001> help") is False
|
|
assert adapter._check_bot_mention("") is False
|
|
|
|
|
|
class TestTextChunking:
|
|
@pytest.mark.asyncio
|
|
async def test_text_within_limit_sent_as_is(self, adapter):
|
|
from unittest.mock import AsyncMock
|
|
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._client = AsyncMock()
|
|
adapter._client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123.456"})
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
text = "x" * 5000
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content=text,
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is True
|
|
call_args = adapter._client.chat_postMessage.call_args
|
|
sent_text = call_args[1]["text"]
|
|
assert sent_text == text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_at_limit_sent_as_is(self, adapter):
|
|
from unittest.mock import AsyncMock
|
|
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._client = AsyncMock()
|
|
adapter._client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123.456"})
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
exact_text = "x" * adapter.text_chunk_limit
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content=exact_text,
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is True
|
|
call_args = adapter._client.chat_postMessage.call_args
|
|
sent_text = call_args[1]["text"]
|
|
assert len(sent_text) == adapter.text_chunk_limit
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_text_exceeds_limit_chunked(self, adapter):
|
|
from unittest.mock import AsyncMock
|
|
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._client = AsyncMock()
|
|
adapter._client.chat_postMessage = AsyncMock(
|
|
side_effect=lambda **kwargs: {"ok": True, "ts": f"ts_{kwargs['channel']}"}
|
|
)
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
long_text = "A" * 10000
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content=long_text,
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is True
|
|
assert adapter._client.chat_postMessage.call_count >= 2
|
|
for call_kwargs in adapter._client.chat_postMessage.call_args_list:
|
|
assert len(call_kwargs[1]["text"]) <= adapter.text_chunk_limit
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chunked_follow_up_messages_threaded(self, adapter):
|
|
from unittest.mock import AsyncMock
|
|
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._client = AsyncMock()
|
|
call_ids = []
|
|
|
|
async def mock_post(**kwargs):
|
|
ts = f"ts_{len(call_ids)}"
|
|
call_ids.append(ts)
|
|
return {"ok": True, "ts": ts}
|
|
|
|
adapter._client.chat_postMessage = AsyncMock(side_effect=mock_post)
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
long_text = "B" * 10000
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content=long_text,
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is True
|
|
assert adapter._client.chat_postMessage.call_count >= 2
|
|
second_call = adapter._client.chat_postMessage.call_args_list[1]
|
|
assert "thread_ts" in second_call[1]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sent_cache_stores_message_ts(self, adapter):
|
|
from unittest.mock import AsyncMock
|
|
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._client = AsyncMock()
|
|
adapter._client.chat_postMessage = AsyncMock(return_value={"ok": True, "ts": "123.456"})
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content="test",
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is True
|
|
cached = await adapter._sent_cache.get("C123")
|
|
assert cached == "123.456"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sent_cache_used_for_subsequent_sends(self, adapter):
|
|
from unittest.mock import AsyncMock
|
|
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
adapter._client = AsyncMock()
|
|
ts_list = []
|
|
|
|
async def mock_post(**kwargs):
|
|
ts_list.append(kwargs)
|
|
return {"ok": True, "ts": f"ts_{len(ts_list)}"}
|
|
|
|
adapter._client.chat_postMessage = AsyncMock(side_effect=mock_post)
|
|
await adapter._sent_cache.put("D123", "cached.thread.ts")
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="D123",
|
|
),
|
|
content="first message",
|
|
)
|
|
await adapter.send(resp)
|
|
first_thread_ts = ts_list[0].get("thread_ts")
|
|
assert first_thread_ts == "cached.thread.ts"
|
|
|
|
|
|
class TestTokenFormatValidation:
|
|
def test_valid_bot_token(self, adapter):
|
|
adapter.config = {"bot_token": "xoxb-valid-token"}
|
|
token = adapter._resolve_bot_token()
|
|
assert token == "xoxb-valid-token"
|
|
|
|
def test_invalid_bot_token_warns(self, adapter):
|
|
adapter.config = {"bot_token": "invalid-token"}
|
|
token = adapter._resolve_bot_token()
|
|
assert token == "invalid-token"
|
|
|
|
def test_valid_app_token(self, adapter):
|
|
adapter.config = {"app_token": "xapp-valid-token"}
|
|
token = adapter._resolve_app_token()
|
|
assert token == "xapp-valid-token"
|
|
|
|
def test_invalid_app_token_warns(self, adapter):
|
|
adapter.config = {"app_token": "invalid-token"}
|
|
token = adapter._resolve_app_token()
|
|
assert token == "invalid-token"
|
|
|
|
def test_empty_token_no_warning(self, adapter):
|
|
adapter.config = {}
|
|
token = adapter._resolve_bot_token()
|
|
assert token == ""
|
|
|
|
|
|
class TestStreamLock:
|
|
def test_lock_initialized(self, adapter):
|
|
import asyncio
|
|
|
|
assert isinstance(adapter._stream_lock, asyncio.Lock)
|
|
|
|
|
|
class TestMaxRetryCount:
|
|
def test_max_retry_count_value(self, adapter):
|
|
assert adapter.MAX_RETRY_COUNT == 3
|
|
|
|
|
|
class TestAsyncSendMethods:
|
|
@pytest.mark.asyncio
|
|
async def test_send_not_connected(self, adapter):
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content="test",
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is False
|
|
assert result.error == "Slack not connected"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_message_not_connected(self, adapter):
|
|
result = await adapter.edit_message("C123", "123.456", "new text")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_message_not_connected(self, adapter):
|
|
result = await adapter.delete_message("C123", "123.456")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_not_connected(self, adapter):
|
|
result = await adapter.send_reaction("C123", "123.456", "thumbsup")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_not_connected(self, adapter):
|
|
result = await adapter.send_media("C123", "png", b"fake_data")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_not_connected(self, adapter):
|
|
result = await adapter.send_stream_chunk("C123", "", "chunk", False)
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_with_slack_api_error(self):
|
|
from unittest.mock import AsyncMock
|
|
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
adapter = SlackAdapter({"bot_token": "xoxb-test", "app_token": "xapp-test"})
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
mock_response = {"ok": False, "error": "not_in_channel"}
|
|
adapter._client = AsyncMock()
|
|
adapter._client.chat_postMessage = AsyncMock(side_effect=SlackApiError("not_in_channel", mock_response))
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content="test",
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is False
|
|
assert result.error == "not_in_channel"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_with_slack_api_error_unified(self):
|
|
from unittest.mock import AsyncMock
|
|
|
|
from slack_sdk.errors import SlackApiError
|
|
|
|
adapter = SlackAdapter({"bot_token": "xoxb-test", "app_token": "xapp-test"})
|
|
adapter._status = ChannelStatus.CONNECTED
|
|
mock_response = {"ok": False, "error": "fatal_error"}
|
|
adapter._client = AsyncMock()
|
|
adapter._client.chat_postMessage = AsyncMock(side_effect=SlackApiError("fatal_error", mock_response))
|
|
|
|
from yuxi.channels.models import ChannelResponse
|
|
|
|
resp = ChannelResponse(
|
|
identity=ChannelIdentity(
|
|
channel_id="slack",
|
|
channel_type=ChannelType.SLACK,
|
|
channel_user_id="U001",
|
|
channel_chat_id="C123",
|
|
),
|
|
content="test",
|
|
)
|
|
result = await adapter.send(resp)
|
|
assert result.success is False
|
|
assert result.error == "fatal_error"
|
|
assert result.metadata.get("error_type") == "slack_api_error"
|
|
|
|
|
|
class TestNewBlockKitFunctions:
|
|
def test_build_header(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_header
|
|
|
|
result = build_header("Test Header")
|
|
assert result["type"] == "header"
|
|
assert result["text"]["type"] == "plain_text"
|
|
assert "Test Header" in result["text"]["text"]
|
|
|
|
def test_build_header_english_adds_emoji(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_header
|
|
|
|
result = build_header("Welcome")
|
|
assert "📌" in result["text"]["text"]
|
|
|
|
def test_build_header_chinese_no_emoji(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_header
|
|
|
|
result = build_header("欢迎")
|
|
assert "欢迎" in result["text"]["text"]
|
|
|
|
def test_build_header_no_emoji(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_header
|
|
|
|
result = build_header("Title", emoji=False)
|
|
assert "📌" not in result["text"]["text"]
|
|
assert result["text"]["text"] == "Title"
|
|
|
|
def test_build_context(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_context
|
|
|
|
result = build_context({"type": "mrkdwn", "text": "Info text"})
|
|
assert result["type"] == "context"
|
|
assert len(result["elements"]) == 1
|
|
assert result["elements"][0]["text"] == "Info text"
|
|
|
|
def test_build_context_multiple_elements(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_context
|
|
|
|
result = build_context(
|
|
{"type": "mrkdwn", "text": "A"},
|
|
{"type": "mrkdwn", "text": "B"},
|
|
)
|
|
assert len(result["elements"]) == 2
|
|
|
|
def test_build_image_block(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_image_block
|
|
|
|
result = build_image_block("https://example.com/img.png", "Alt text")
|
|
assert result["type"] == "image"
|
|
assert result["image_url"] == "https://example.com/img.png"
|
|
assert result["alt_text"] == "Alt text"
|
|
assert "title" not in result
|
|
|
|
def test_build_image_block_with_title(self):
|
|
from yuxi.channels.adapters.slack.blocks import build_image_block
|
|
|
|
result = build_image_block("https://example.com/img.png", "Alt", title="My Image")
|
|
assert result["title"]["text"] == "My Image"
|
|
|
|
|
|
class TestProbe:
|
|
@pytest.mark.asyncio
|
|
async def test_probe_bot_token_invalid_prefix(self):
|
|
from yuxi.channels.adapters.slack.probe import probe_bot_token
|
|
|
|
result = await probe_bot_token("invalid-token")
|
|
assert result["status"] == "error"
|
|
assert "xoxb-" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_app_token_invalid_prefix(self):
|
|
from yuxi.channels.adapters.slack.probe import probe_app_token
|
|
|
|
result = await probe_app_token("invalid-token")
|
|
assert result["status"] == "error"
|
|
assert "xapp-" in result["message"]
|
|
|
|
|
|
class TestStreamCleanup:
|
|
def test_cleanup_task_initialized(self, adapter):
|
|
import asyncio
|
|
|
|
assert adapter._stream_cleanup_task is None
|
|
assert adapter._stream_cleanup_interval_s == 60
|
|
|
|
def test_cleanup_removes_stale_entries(self, adapter):
|
|
adapter._streaming_messages["stale"] = {"_last_update": 0}
|
|
adapter._streaming_messages["fresh"] = {"_last_update": 9999999999.0}
|
|
adapter._cleanup_stale_streams(9999999999.0)
|
|
assert "stale" not in adapter._streaming_messages
|
|
assert "fresh" in adapter._streaming_messages
|
|
|
|
|
|
class TestAnalysisPipeline:
|
|
pytestmark = pytest.mark.skip(reason="Analysis module not yet implemented")
|
|
|
|
|
|
class TestEventDedup:
|
|
def test_is_duplicate_event_first_occurrence(self, adapter):
|
|
adapter._processed_event_ids.clear()
|
|
assert adapter._is_duplicate_event("evt-001") is False
|
|
assert "evt-001" in adapter._processed_event_ids
|
|
|
|
def test_is_duplicate_event_second_occurrence(self, adapter):
|
|
adapter._processed_event_ids.clear()
|
|
adapter._processed_event_ids["evt-001"] = None
|
|
assert adapter._is_duplicate_event("evt-001") is True
|
|
|
|
def test_processed_event_ids_max_size_eviction(self, adapter):
|
|
adapter._processed_event_ids.clear()
|
|
adapter.MAX_PROCESSED_EVENT_IDS = 5
|
|
for i in range(5):
|
|
adapter._is_duplicate_event(f"evt-{i}")
|
|
assert len(adapter._processed_event_ids) == 5
|
|
|
|
adapter._is_duplicate_event("evt-new")
|
|
assert len(adapter._processed_event_ids) == 5
|
|
assert "evt-0" not in adapter._processed_event_ids
|
|
assert "evt-new" in adapter._processed_event_ids
|
|
|
|
|
|
class TestConnectionDegradation:
|
|
def test_last_channel_event_at_initialized(self, adapter):
|
|
assert adapter._last_channel_event_at == 0.0
|
|
|
|
def test_channel_heartbeat_timeout_constant(self, adapter):
|
|
assert adapter.CHANNEL_EVENT_HEARTBEAT_TIMEOUT_S == 600
|
|
|
|
def test_heartbeat_task_initialized(self, adapter):
|
|
assert adapter._channel_heartbeat_task is None
|
|
|
|
def test_reconnect_count_initialized(self, adapter):
|
|
assert adapter._reconnect_count == 0
|
|
|
|
|
|
class TestTooManyWebsockets:
|
|
def test_class_constants(self, adapter):
|
|
assert adapter.MAX_RETRY_COUNT == 3
|
|
assert adapter.MAX_PROCESSED_EVENT_IDS == 1000
|
|
|
|
|
|
class TestRequireMention:
|
|
@pytest.mark.asyncio
|
|
async def test_group_message_without_mention_blocked(self):
|
|
adapter = SlackAdapter({"bot_token": "xoxb-test", "app_token": "xapp-test", "group_policy": "open"})
|
|
adapter._bot_user_id = "BOT123"
|
|
adapter._bot_id = "B456"
|
|
adapter._security_config.require_mention = True
|
|
msg = adapter.normalize_inbound(
|
|
_sample_msg_event(
|
|
event={"channel": "C1234567", "text": "Hello everyone"},
|
|
)
|
|
)
|
|
decision = await adapter._check_security(msg)
|
|
assert decision.allowed is False
|
|
assert "require_mention" in decision.reason
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_message_with_mention_allowed(self):
|
|
adapter = SlackAdapter({"bot_token": "xoxb-test", "app_token": "xapp-test", "group_policy": "open"})
|
|
adapter._bot_user_id = "BOT123"
|
|
adapter._bot_id = "B456"
|
|
adapter._security_config.require_mention = True
|
|
msg = adapter.normalize_inbound(
|
|
_sample_msg_event(
|
|
event={"channel": "C1234567", "text": f"Hey <@{adapter._bot_user_id}> help"},
|
|
)
|
|
)
|
|
decision = await adapter._check_security(msg)
|
|
assert decision.allowed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_message_require_mention_false_default(self):
|
|
adapter = SlackAdapter({"bot_token": "xoxb-test", "app_token": "xapp-test", "group_policy": "open"})
|
|
msg = adapter.normalize_inbound(_sample_msg_event(event={"channel": "C1234567", "text": "Hello"}))
|
|
decision = await adapter._check_security(msg)
|
|
assert decision.allowed is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dm_message_ignores_require_mention(self):
|
|
adapter = SlackAdapter({"bot_token": "xoxb-test", "app_token": "xapp-test", "dm_policy": "open"})
|
|
adapter._bot_user_id = "BOT123"
|
|
adapter._security_config.require_mention = True
|
|
msg = adapter.normalize_inbound(
|
|
_sample_msg_event(
|
|
event={"channel": "D1234567", "text": "Hello bot"},
|
|
)
|
|
)
|
|
decision = await adapter._check_security(msg)
|
|
assert decision.allowed is True
|
|
|
|
|
|
class TestTokenAppIdVerification:
|
|
def test_matching_app_ids_no_warning(self, adapter):
|
|
adapter._verify_token_app_id(
|
|
"xoxb-12345-abc-def",
|
|
"xapp-12345-xyz",
|
|
{},
|
|
)
|
|
|
|
def test_mismatched_app_ids(self, adapter):
|
|
adapter._verify_token_app_id(
|
|
"xoxb-11111-abc",
|
|
"xapp-22222-xyz",
|
|
{},
|
|
)
|
|
|
|
def test_extract_app_id_from_token(self, adapter):
|
|
assert adapter._extract_app_id_from_token("xoxb-12345-rest", "xoxb-") == "12345"
|
|
assert adapter._extract_app_id_from_token("xapp-67890-rest", "xapp-") == "67890"
|
|
|
|
def test_extract_app_id_invalid_prefix(self, adapter):
|
|
assert adapter._extract_app_id_from_token("invalid-token", "xoxb-") == ""
|
|
|
|
def test_extract_app_id_empty_token(self, adapter):
|
|
assert adapter._extract_app_id_from_token("", "xoxb-") == ""
|
|
|
|
def test_extract_app_id_no_dash(self, adapter):
|
|
assert adapter._extract_app_id_from_token("xoxb-12345", "xoxb-") == "12345"
|
|
|
|
|
|
class TestPairingCodeNormalization:
|
|
def test_normalize_strips_slack_prefix(self):
|
|
from yuxi.channels.adapters.slack.pairing import _normalize_code
|
|
|
|
assert _normalize_code("slack:ABC123") == "ABC123"
|
|
assert _normalize_code("Slack:ABC123") == "ABC123"
|
|
assert _normalize_code("SLACK:ABC123") == "ABC123"
|
|
|
|
def test_normalize_no_prefix_passthrough(self):
|
|
from yuxi.channels.adapters.slack.pairing import _normalize_code
|
|
|
|
assert _normalize_code("ABC123") == "ABC123"
|
|
assert _normalize_code("other:ABC123") == "other:ABC123"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_with_prefix(self):
|
|
from yuxi.channels.adapters.slack.pairing import PairingManager
|
|
|
|
mgr = PairingManager(ttl_seconds=600)
|
|
decision = await mgr.generate_pairing("U001")
|
|
assert decision.pairing_code is not None
|
|
|
|
user_id = await mgr.approve(f"slack:{decision.pairing_code}")
|
|
assert user_id == "U001"
|
|
assert mgr.is_approved("U001")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approve_without_prefix(self):
|
|
from yuxi.channels.adapters.slack.pairing import PairingManager
|
|
|
|
mgr = PairingManager(ttl_seconds=600)
|
|
decision = await mgr.generate_pairing("U002")
|
|
user_id = await mgr.approve(decision.pairing_code)
|
|
assert user_id == "U002"
|
|
|
|
|
|
class TestHTTPMode:
|
|
def test_http_mode_config(self):
|
|
adapter = SlackAdapter(
|
|
{
|
|
"mode": "http",
|
|
"bot_token": "xoxb-test-token",
|
|
"signing_secret": "test-secret",
|
|
}
|
|
)
|
|
assert adapter._mode == "http"
|
|
assert adapter._signing_secret == "test-secret"
|
|
|
|
def test_socket_mode_default(self, adapter):
|
|
assert adapter._mode == "socket"
|
|
|
|
def test_http_mode_requires_signing_secret(self):
|
|
adapter = SlackAdapter({"mode": "http", "bot_token": "xoxb-test-token"})
|
|
assert adapter._signing_secret == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_http_missing_signing_secret(self):
|
|
from yuxi.channels.exceptions import ChannelAuthenticationError
|
|
|
|
adapter = SlackAdapter({"mode": "http", "bot_token": "xoxb-test-token"})
|
|
with pytest.raises(ChannelAuthenticationError) as exc_info:
|
|
await adapter._connect_http("xoxb-test-token")
|
|
assert "signing_secret" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_http_success(self):
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
adapter = SlackAdapter(
|
|
{
|
|
"mode": "http",
|
|
"bot_token": "xoxb-test-token",
|
|
"signing_secret": "test-secret",
|
|
}
|
|
)
|
|
adapter._resolve_bot_token = lambda: "xoxb-test-token"
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.auth_test = AsyncMock(
|
|
return_value={
|
|
"ok": True,
|
|
"user_id": "U001",
|
|
"user": "testbot",
|
|
"team": "Test Team",
|
|
"team_id": "T001",
|
|
}
|
|
)
|
|
mock_client.retry_handlers = []
|
|
|
|
with patch(
|
|
"yuxi.channels.adapters.slack.adapter.AsyncWebClient",
|
|
return_value=mock_client,
|
|
):
|
|
await adapter._connect_http("xoxb-test-token")
|
|
|
|
assert adapter._status == ChannelStatus.CONNECTED
|
|
assert adapter._bot_user_id == "U001"
|
|
assert adapter._team == "Test Team"
|
|
|
|
|
|
class TestWebhookSignature:
|
|
@pytest.mark.asyncio
|
|
async def test_verify_signature_no_secret(self, adapter):
|
|
adapter._signing_secret = ""
|
|
result = await adapter.verify_webhook_signature({}, b"body")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_signature_missing_headers(self, adapter):
|
|
adapter._signing_secret = "test-secret"
|
|
result = await adapter.verify_webhook_signature({}, b"body")
|
|
assert result is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_signature_invalid_timestamp(self, adapter):
|
|
adapter._signing_secret = "test-secret"
|
|
result = await adapter.verify_webhook_signature(
|
|
{"x-slack-request-timestamp": "not-a-number", "x-slack-signature": "v0=abc"},
|
|
b"body",
|
|
)
|
|
assert result is False
|