357 lines
14 KiB
Python
357 lines
14 KiB
Python
|
|
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"]
|