1. webhook路由新增记录请求源IP与请求头 2. 修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑 3. 新增Nostr适配器测试通用fixture 4. 更新NextcloudTalk签名相关测试与函数命名
1062 lines
38 KiB
Python
1062 lines
38 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.GROUP
|
|
|
|
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 ChannelResponse, Attachment
|
|
|
|
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 = 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)
|
|
adapter._sent_cache.put("C123", "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="C123",
|
|
),
|
|
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:
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_runs_all_agents(self):
|
|
from yuxi.channels.adapters.slack.analysis import SlackAnalysisPipeline
|
|
|
|
pipeline = SlackAnalysisPipeline()
|
|
result = await pipeline.run()
|
|
|
|
assert result["web_research"] is not None
|
|
assert result["code_function"] is not None
|
|
assert result["code_issue"] is not None
|
|
assert result["gap_report"] is not None
|
|
assert result["fix_plan"] is not None
|
|
assert result["summary"]["total_issues"] > 0
|
|
assert result["summary"]["total_fixes"] > 0
|
|
assert result["summary"]["high_priority"] >= 1
|
|
assert result["summary"]["duration_seconds"] >= 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pipeline_export_report_json(self, tmp_path):
|
|
from yuxi.channels.adapters.slack.analysis import SlackAnalysisPipeline
|
|
|
|
pipeline = SlackAnalysisPipeline()
|
|
await pipeline.run()
|
|
|
|
report_path = tmp_path / "slack_analysis.json"
|
|
pipeline.export_report(str(report_path))
|
|
|
|
assert report_path.exists()
|
|
content = report_path.read_text(encoding="utf-8")
|
|
assert "gap_analysis" in content
|
|
assert "fix_plan" in content
|
|
|
|
def test_pipeline_state_initial(self):
|
|
from yuxi.channels.adapters.slack.analysis import SlackAnalysisState
|
|
|
|
state = SlackAnalysisState()
|
|
assert state.web_research_result is None
|
|
assert state.code_function_result is None
|
|
assert state.code_issue_result is None
|
|
assert state.gap_report is None
|
|
assert state.fix_plan is None
|
|
assert len(state.completed_phases) == 0
|
|
|
|
|
|
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.add("evt-001")
|
|
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) == 1
|
|
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 = 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 = 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 = 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 = 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 |