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