ForcePilot/backend/test/unit/channels/test_channels_slack_adapter.py
Kris fbe47b6393 refactor(webhook,tests): 完善webhook日志与多适配器测试修复
1.  webhook路由新增记录请求源IP与请求头
2.  修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑
3.  新增Nostr适配器测试通用fixture
4.  更新NextcloudTalk签名相关测试与函数命名
2026-05-12 14:52:12 +08:00

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