ForcePilot/backend/test/unit/channels/test_channels_slack_adapter.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
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重连相关测试
2026-05-13 16:43:01 +08:00

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