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