from __future__ import annotations from unittest.mock import AsyncMock, MagicMock, patch import pytest from yuxi.channels.adapters.twitch.actions import ( _build_response, describe_message_tool, extract_target_from_args, get_action_stats, handle_action, resolve_execution_mode, supports_action, ) from yuxi.channels.models import ChannelType, DeliveryResult from yuxi.channels.protocols.actions import ActionContext def make_adapter_mock(**kwargs): adapter = MagicMock() adapter.channel_type = ChannelType.TWITCH adapter.config = kwargs.get("config", {}) adapter._helix = kwargs.get("_helix", None) adapter._bot_user_id = kwargs.get("_bot_user_id", None) adapter._outbound_cache = kwargs.get("_outbound_cache", MagicMock()) adapter._resolve_broadcaster_id = AsyncMock(return_value=kwargs.get("broadcaster_id", "123")) adapter._send_via_helix = AsyncMock(return_value=DeliveryResult(success=True)) adapter.send = AsyncMock(return_value=DeliveryResult(success=True)) adapter.send_media = AsyncMock(return_value=DeliveryResult(success=True)) adapter.format_outbound = MagicMock(return_value={"target": "#test_channel"}) return adapter def make_ctx(**kwargs): action = kwargs.pop("action", "send") chat_id = kwargs.pop("chat_id", "#test_channel") msg_id = kwargs.pop("msg_id", "msg_test_123") args = dict(kwargs) if "chat_id" not in args: args["chat_id"] = chat_id if "msg_id" not in args: args["msg_id"] = msg_id return ActionContext( action=action, channel_id="twitch", chat_id=chat_id, msg_id=msg_id, args=args, ) class TestBuildResponse: def test_builds_response(self): adapter = make_adapter_mock() ctx = make_ctx(action="send", content="Hello") response = _build_response(ctx, adapter) assert response is not None assert response.content == "Hello" assert response.identity.channel_chat_id == "#test_channel" def test_returns_none_when_no_chat_id(self): adapter = make_adapter_mock() ctx = make_ctx(action="send", chat_id="", content="Hello") response = _build_response(ctx, adapter) assert response is None def test_returns_none_when_no_content(self): adapter = make_adapter_mock() ctx = make_ctx(action="send", chat_id="#test", content="") response = _build_response(ctx, adapter) assert response is None def test_uses_media_url_for_send_media(self): adapter = make_adapter_mock() ctx = make_ctx(action="send_media", content_key="media_url", chat_id="#test", media_url="http://img.png") response = _build_response(ctx, adapter, "media_url") assert response is not None assert response.content == "http://img.png" class TestHandleActionSend: @pytest.mark.asyncio async def test_send_action(self): adapter = make_adapter_mock() ctx = make_ctx(action="send", content="Hello world") result = await handle_action(ctx, adapter) assert result.success is True @pytest.mark.asyncio async def test_send_missing_chat_id(self): adapter = make_adapter_mock() ctx = make_ctx(action="send", chat_id="", content="Hello") result = await handle_action(ctx, adapter) assert result.success is False assert "chat_id" in result.error @pytest.mark.asyncio async def test_send_missing_content(self): adapter = make_adapter_mock() ctx = make_ctx(action="send", chat_id="#test", content="") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_send_via_helix_preferred(self): adapter = make_adapter_mock(config={"prefer_helix_send": True}) adapter._send_via_helix = AsyncMock(return_value=DeliveryResult(success=True)) ctx = make_ctx(action="send", content="Hello") result = await handle_action(ctx, adapter) assert result.success is True adapter._send_via_helix.assert_called_once() @pytest.mark.asyncio async def test_send_via_helix_falls_back_to_irc(self): adapter = make_adapter_mock(config={"prefer_helix_send": True}) adapter._send_via_helix = AsyncMock(return_value=DeliveryResult(success=False, error="fail")) adapter.send = AsyncMock(return_value=DeliveryResult(success=True)) ctx = make_ctx(action="send", content="Hello") result = await handle_action(ctx, adapter) assert result.success is True class TestHandleActionSendMedia: @pytest.mark.asyncio async def test_send_media_action(self): adapter = make_adapter_mock() ctx = make_ctx(action="send_media", media_url="http://img.png") result = await handle_action(ctx, adapter) assert result.success is True @pytest.mark.asyncio async def test_send_media_missing_chat_id(self): adapter = make_adapter_mock() ctx = make_ctx(action="send_media", chat_id="", media_url="http://img.png") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_send_media_missing_url(self): adapter = make_adapter_mock() ctx = make_ctx(action="send_media", chat_id="#test", media_url="") result = await handle_action(ctx, adapter) assert result.success is False class TestHandleActionReply: @pytest.mark.asyncio async def test_reply_action(self): adapter = make_adapter_mock() ctx = make_ctx(action="reply", content="Reply text", reply_to_msg_id="msg123", reply_to_username="user1") result = await handle_action(ctx, adapter) assert result.success is True adapter._send_via_helix.assert_called_once() @pytest.mark.asyncio async def test_reply_missing_chat_id(self): adapter = make_adapter_mock() ctx = make_ctx(action="reply", chat_id="", content="Reply", reply_to_msg_id="msg123") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_reply_missing_content(self): adapter = make_adapter_mock() ctx = make_ctx(action="reply", chat_id="#test", content="", reply_to_msg_id="msg123") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_reply_missing_reply_to_msg_id(self): adapter = make_adapter_mock() ctx = make_ctx(action="reply", chat_id="#test", content="Reply", reply_to_msg_id="") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_reply_falls_back_to_irc_with_prefix(self): adapter = make_adapter_mock() adapter._send_via_helix = AsyncMock(return_value=DeliveryResult(success=False, error="fail")) adapter.send = AsyncMock(return_value=DeliveryResult(success=True)) ctx = make_ctx(action="reply", content="Reply text", reply_to_msg_id="msg123", reply_to_username="TestUser") result = await handle_action(ctx, adapter) assert result.success is True assert "@TestUser" in adapter.send.call_args[0][0].content class TestHandleActionUnsend: @pytest.mark.asyncio async def test_unsend_action(self): adapter = make_adapter_mock(_helix=MagicMock(), _bot_user_id="bot123", broadcaster_id="12345") adapter._helix.delete_chat_message = AsyncMock(return_value=True) ctx = make_ctx(action="unsend", message_id="msg_delete_123") result = await handle_action(ctx, adapter) assert result.success is True @pytest.mark.asyncio async def test_unsend_missing_helix(self): adapter = make_adapter_mock(_helix=None) ctx = make_ctx(action="unsend", message_id="msg123") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_delete_message_action(self): adapter = make_adapter_mock(_helix=MagicMock(), _bot_user_id="bot123", broadcaster_id="12345") adapter._helix.delete_chat_message = AsyncMock(return_value=True) ctx = make_ctx(action="delete_message", message_id="msg123") result = await handle_action(ctx, adapter) assert result.success is True @pytest.mark.asyncio async def test_unsend_no_broadcaster_id(self): adapter = make_adapter_mock(_helix=MagicMock(), _bot_user_id="bot123", broadcaster_id=None) adapter._resolve_broadcaster_id = AsyncMock(return_value=None) adapter._helix.delete_chat_message = AsyncMock(return_value=True) ctx = make_ctx(action="unsend", message_id="msg123") result = await handle_action(ctx, adapter) assert result.success is False assert "broadcaster" in result.error @pytest.mark.asyncio async def test_unsend_no_bot_user_id(self): adapter = make_adapter_mock(_helix=MagicMock(), _bot_user_id=None, broadcaster_id="12345") adapter._helix.delete_chat_message = AsyncMock(return_value=True) ctx = make_ctx(action="unsend", message_id="msg123") result = await handle_action(ctx, adapter) assert result.success is False assert "bot_user_id" in result.error class TestHandleActionAnnouncement: @pytest.mark.asyncio async def test_announcement_action(self): adapter = make_adapter_mock(_helix=MagicMock(), _bot_user_id="bot123", broadcaster_id="12345") adapter._helix.send_chat_announcement = AsyncMock(return_value=True) ctx = make_ctx(action="announcement", content="Big news!", color="blue") result = await handle_action(ctx, adapter) assert result.success is True @pytest.mark.asyncio async def test_announcement_missing_helix(self): adapter = make_adapter_mock(_helix=None) ctx = make_ctx(action="announcement", content="News") result = await handle_action(ctx, adapter) assert result.success is False @pytest.mark.asyncio async def test_announcement_no_broadcaster_id(self): adapter = make_adapter_mock(_helix=MagicMock(), _bot_user_id="bot123", broadcaster_id=None) adapter._resolve_broadcaster_id = AsyncMock(return_value=None) ctx = make_ctx(action="announcement", content="News") result = await handle_action(ctx, adapter) assert result.success is False class TestHandleActionUnsupported: @pytest.mark.asyncio async def test_edit_action_unsupported(self): adapter = make_adapter_mock() ctx = make_ctx(action="edit", content="new content") result = await handle_action(ctx, adapter) assert result.success is False assert "editing" in result.error @pytest.mark.asyncio async def test_reactions_unsupported(self): adapter = make_adapter_mock() ctx = make_ctx(action="send_reaction") result = await handle_action(ctx, adapter) assert result.success is False assert "reactions" in result.error @pytest.mark.asyncio async def test_polls_unsupported(self): adapter = make_adapter_mock() ctx = make_ctx(action="polls") result = await handle_action(ctx, adapter) assert result.success is False assert "polls" in result.error @pytest.mark.asyncio async def test_unknown_action(self): adapter = make_adapter_mock() ctx = make_ctx(action="some_random_action") result = await handle_action(ctx, adapter) assert result.success is False assert "unknown action" in result.error class TestSupportsAction: def test_supported_actions(self): for action in ("send", "send_media", "reply", "unsend", "delete_message", "announcement"): assert supports_action(action) is True def test_unsupported_actions(self): for action in ("edit", "reactions", "polls", "pin"): assert supports_action(action) is False def test_unknown_action(self): assert supports_action("random_action") is False class TestDescribeMessageTool: def test_returns_dict_with_all_actions(self): result = describe_message_tool() assert isinstance(result, dict) assert "send" in result assert "send_media" in result assert "reply" in result assert "unsend" in result assert "delete_message" in result assert "announcement" in result def test_each_action_has_description(self): result = describe_message_tool() for action_info in result.values(): assert "description" in action_info def test_each_action_has_parameters(self): result = describe_message_tool() for action_info in result.values(): assert "parameters" in action_info class TestExtractTargetFromArgs: def test_extracts_chat_id(self): result = extract_target_from_args({"chat_id": "#test_channel"}) assert result == {"chat_id": "#test_channel"} def test_returns_none_when_no_chat_id(self): result = extract_target_from_args({"other_key": "value"}) assert result is None def test_returns_none_for_empty_dict(self): assert extract_target_from_args({}) is None class TestResolveExecutionMode: def test_always_returns_send(self): assert resolve_execution_mode("send") == "send" assert resolve_execution_mode("reply") == "send" assert resolve_execution_mode("unknown") == "send" class TestGetActionStats: def test_returns_stats_dict(self): result = get_action_stats() assert "implemented" in result assert "planned" in result assert "unsupported" in result def test_unimplemented_actions_in_unsupported(self): result = get_action_stats() assert "edit" in result["unsupported"] assert "reactions" in result["unsupported"] assert "polls" in result["unsupported"]