新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
249 lines
8.3 KiB
Python
249 lines
8.3 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.twitch.eventsub_client import EventSubListener
|
|
from yuxi.channels.adapters.twitch.helix import HelixClient
|
|
from yuxi.channels.models import (
|
|
ChatType,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
def _make_listener():
|
|
helix = HelixClient("dummy_id", "dummy_token")
|
|
return EventSubListener(
|
|
helix=helix,
|
|
app_access_token="app_token",
|
|
broadcaster_ids=["12345"],
|
|
)
|
|
|
|
|
|
class TestEventSubNormalize:
|
|
def setup_method(self):
|
|
self.listener = _make_listener()
|
|
|
|
def test_normalize_follow(self):
|
|
event = {
|
|
"id": "follow-001",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Follower1",
|
|
}
|
|
msg = self.listener._normalize_follow(event, "12345")
|
|
assert msg is not None
|
|
assert msg.event_type == EventType.SYSTEM_EVENT
|
|
assert "followed" in msg.content
|
|
assert msg.metadata["event"] == "follow"
|
|
assert msg.identity.channel_user_id == "678"
|
|
|
|
def test_normalize_subscription(self):
|
|
event = {
|
|
"id": "sub-001",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Subscriber1",
|
|
"tier": "1000",
|
|
}
|
|
msg = EventSubListener._normalize_subscription(event, "12345")
|
|
assert msg is not None
|
|
assert "subscribed" in msg.content
|
|
assert msg.metadata["event"] == "subscription"
|
|
assert msg.metadata["tier"] == "1000"
|
|
|
|
def test_normalize_subscription_message(self):
|
|
event = {
|
|
"id": "resub-001",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Resubber1",
|
|
"tier": "2000",
|
|
"cumulative_months": 12,
|
|
"message": {"text": "I love this channel!"},
|
|
}
|
|
msg = EventSubListener._normalize_subscription_message(event, "12345")
|
|
assert msg is not None
|
|
assert "resubscribed" in msg.content
|
|
assert msg.metadata["cumulative_months"] == 12
|
|
assert msg.metadata["tier"] == "2000"
|
|
|
|
def test_normalize_subscription_gift(self):
|
|
event = {
|
|
"id": "gift-001",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Gifter1",
|
|
"total": 5,
|
|
"tier": "1000",
|
|
}
|
|
msg = EventSubListener._normalize_subscription_gift(event, "12345")
|
|
assert msg is not None
|
|
assert "gifted" in msg.content
|
|
assert msg.metadata["total"] == 5
|
|
|
|
def test_normalize_cheer(self):
|
|
event = {
|
|
"id": "cheer-001",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Cheerer1",
|
|
"bits": 1000,
|
|
"message": "Go team!",
|
|
}
|
|
msg = EventSubListener._normalize_cheer(event, "12345")
|
|
assert msg is not None
|
|
assert "cheered" in msg.content
|
|
assert msg.metadata["bits"] == 1000
|
|
|
|
def test_normalize_raid(self):
|
|
event = {
|
|
"id": "raid-001",
|
|
"broadcaster_user_id": "12345",
|
|
"from_broadcaster_user_id": "999",
|
|
"from_broadcaster_user_name": "RaiderChannel",
|
|
"viewers": 50,
|
|
}
|
|
msg = EventSubListener._normalize_raid(event, "12345")
|
|
assert msg is not None
|
|
assert "raided" in msg.content
|
|
assert msg.metadata["viewers"] == 50
|
|
assert msg.metadata["from_broadcaster_name"] == "RaiderChannel"
|
|
|
|
def test_normalize_channel_points(self):
|
|
event = {
|
|
"id": "cp-001",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Viewer1",
|
|
"user_input": "Hello!",
|
|
"reward": {"title": "Shoutout", "cost": 500, "id": "reward-1"},
|
|
"status": "unfulfilled",
|
|
}
|
|
msg = EventSubListener._normalize_channel_points(event, "12345")
|
|
assert msg is not None
|
|
assert msg.content == "Hello!"
|
|
assert msg.metadata["reward_title"] == "Shoutout"
|
|
|
|
def test_normalize_hype_train(self):
|
|
event = {
|
|
"id": "ht-001",
|
|
"broadcaster_user_id": "12345",
|
|
"level": 3,
|
|
"progress": 200,
|
|
"goal": 500,
|
|
"total": 150,
|
|
"top_contributions": [],
|
|
}
|
|
msg = EventSubListener._normalize_hype_train(event, "12345")
|
|
assert msg is not None
|
|
assert "Hype Train" in msg.content
|
|
assert msg.metadata["level"] == 3
|
|
|
|
def test_normalize_event_routing_follow(self):
|
|
event_data = {
|
|
"subscription": {"type": "channel.follow"},
|
|
"event": {
|
|
"id": "f-1",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Follower1",
|
|
},
|
|
}
|
|
msg = self.listener._normalize_event(event_data)
|
|
assert msg is not None
|
|
assert "followed" in msg.content
|
|
|
|
def test_normalize_event_routing_raid(self):
|
|
event_data = {
|
|
"subscription": {"type": "channel.raid"},
|
|
"event": {
|
|
"id": "r-1",
|
|
"broadcaster_user_id": "12345",
|
|
"from_broadcaster_user_id": "999",
|
|
"from_broadcaster_user_name": "Raider",
|
|
"viewers": 10,
|
|
},
|
|
}
|
|
msg = self.listener._normalize_event(event_data)
|
|
assert msg is not None
|
|
assert "raided" in msg.content
|
|
|
|
def test_normalize_event_unknown_type(self):
|
|
event_data = {
|
|
"subscription": {"type": "unknown.event"},
|
|
"event": {"id": "x-1", "broadcaster_user_id": "12345"},
|
|
}
|
|
msg = self.listener._normalize_event(event_data)
|
|
assert msg is None
|
|
|
|
def test_normalize_event_with_subscription_message(self):
|
|
event_data = {
|
|
"subscription": {"type": "channel.subscription.message"},
|
|
"event": {
|
|
"id": "rm-1",
|
|
"broadcaster_user_id": "12345",
|
|
"user_id": "678",
|
|
"user_name": "Resubber",
|
|
"tier": "3000",
|
|
"cumulative_months": 24,
|
|
"message": {"text": "Best channel ever!"},
|
|
},
|
|
}
|
|
msg = self.listener._normalize_event(event_data)
|
|
assert msg is not None
|
|
assert msg.metadata["event"] == "resubscription"
|
|
|
|
|
|
class TestEventSubConstants:
|
|
def test_has_follow_subscription(self):
|
|
types = EventSubListener.SUBSCRIPTION_TYPES
|
|
type_names = [t["type"] for t in types]
|
|
assert "channel.follow" in type_names
|
|
|
|
def test_has_raid_subscription(self):
|
|
types = EventSubListener.SUBSCRIPTION_TYPES
|
|
type_names = [t["type"] for t in types]
|
|
assert "channel.raid" in type_names
|
|
|
|
def test_has_subscription_message(self):
|
|
types = EventSubListener.SUBSCRIPTION_TYPES
|
|
type_names = [t["type"] for t in types]
|
|
assert "channel.subscription.message" in type_names
|
|
|
|
def test_keepalive_timeout_reasonable(self):
|
|
assert 10 <= EventSubListener.KEEPALIVE_TIMEOUT <= 30
|
|
|
|
def test_max_reconnect_delay_reasonable(self):
|
|
assert EventSubListener.MAX_RECONNECT_DELAY >= 60
|
|
|
|
|
|
class TestEventSubMessageLoopExceptions:
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_error_propagates(self):
|
|
helix = HelixClient(client_id="test_id", access_token="test_token")
|
|
listener = EventSubListener(helix, "test_app_token", ["12345"])
|
|
listener._ws = AsyncMock()
|
|
listener._ws.receive_json = AsyncMock(
|
|
side_effect=asyncio.CancelledError()
|
|
)
|
|
listener._ws.close = AsyncMock()
|
|
listener._running = True
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await listener._message_loop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_general_exception_breaks_loop(self):
|
|
helix = HelixClient(client_id="test_id", access_token="test_token")
|
|
listener = EventSubListener(helix, "test_app_token", ["12345"])
|
|
listener._ws = AsyncMock()
|
|
listener._ws.receive_json = AsyncMock(side_effect=RuntimeError("test error"))
|
|
listener._ws.close = AsyncMock()
|
|
listener._running = True
|
|
|
|
await listener._message_loop()
|
|
assert listener._running is True |