ForcePilot/backend/test/unit/channels/test_twitch_adapter.py
Kris 3264900bc9 test: 新增多渠道单元测试用例并配置测试环境变量
新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块
同时在测试配置中添加了测试用的OpenAI API密钥环境变量
2026-05-12 00:56:47 +08:00

340 lines
11 KiB
Python

from __future__ import annotations
import pytest
from yuxi.channels.adapters.twitch.adapter import TwitchAdapter, _find_utf8_cut
from yuxi.channels.adapters.twitch.irc_parser import ParsedIRCMessage
from yuxi.channels.adapters.twitch.markdown_utils import strip_twitch_markdown
from yuxi.channels.models import (
ChannelIdentity,
ChannelResponse,
ChannelStatus,
ChannelType,
DeliveryResult,
)
def make_adapter(config=None):
cfg = config or {"client_id": "test_id", "access_token": "test_token", "bot_username": "testbot"}
return TwitchAdapter(cfg)
class TestClassVars:
def test_channel_id(self):
assert TwitchAdapter.channel_id == "twitch"
def test_channel_type(self):
assert TwitchAdapter.channel_type == ChannelType.TWITCH
def test_text_chunk_limit(self):
assert TwitchAdapter.text_chunk_limit == 500
def test_supports_markdown(self):
assert TwitchAdapter.supports_markdown is False
def test_supports_media(self):
assert TwitchAdapter.supports_media is False
def test_supports_streaming(self):
assert TwitchAdapter.supports_streaming is True
def test_streaming_modes(self):
assert "off" in TwitchAdapter.streaming_modes
assert "block" in TwitchAdapter.streaming_modes
class TestInit:
def test_initial_status_disconnected(self):
adapter = make_adapter()
assert adapter._status == ChannelStatus.DISCONNECTED
def test_circuit_breaker_initialized(self):
adapter = make_adapter()
assert adapter._circuit_breaker is not None
def test_stores_config(self):
cfg = {"client_id": "cid", "access_token": "tok", "bot_username": "bot"}
adapter = TwitchAdapter(cfg)
assert adapter.config == cfg
class TestFormatOutbound:
def test_formats_privmsg(self):
adapter = make_adapter()
identity = ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id="",
channel_chat_id="#test_channel",
)
response = ChannelResponse(identity=identity, content="Hello world")
result = adapter.format_outbound(response)
assert result["command"] == "PRIVMSG"
assert result["target"] == "#test_channel"
assert result["text"] == "Hello world"
class TestNormalizeInbound:
def test_delegates_to_normalizer(self):
adapter = make_adapter()
parsed = ParsedIRCMessage(
command="PRIVMSG",
prefix="user!user@host",
params=["#test_channel"],
trailing="Hello world",
tags={"user-id": "123", "display-name": "TestUser", "tmi-sent-ts": "1600000000000"},
raw="",
)
msg = adapter.normalize_inbound(parsed)
assert msg is not None
assert msg.content == "Hello world"
assert msg.identity.channel_id == "twitch"
def test_returns_none_for_skip_commands(self):
adapter = make_adapter()
parsed = ParsedIRCMessage(
command="JOIN",
prefix="user!user@host",
params=["#test_channel"],
trailing="",
tags={},
raw="",
)
msg = adapter.normalize_inbound(parsed)
assert msg is None
class TestSplitIRCText:
def test_short_text_no_split(self):
chunks = TwitchAdapter._split_irc_text("hello", "#chan")
assert len(chunks) == 1
assert chunks[0] == "hello"
def test_long_text_splits(self):
text = "A" * 600
chunks = TwitchAdapter._split_irc_text(text, "#chan")
assert len(chunks) > 1
def test_split_preserves_content(self):
text = "This is a test message that should be split properly"
chunks = TwitchAdapter._split_irc_text(text, "#chan")
assert "".join(chunks) == text
def test_split_prefers_newline(self):
text = "Line 1\nLine 2\nLine 3"
text_long = text * 50
chunks = TwitchAdapter._split_irc_text(text_long, "#chan")
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= 500
class TestFindUTF8Cut:
def test_simple_cut(self):
encoded = b"hello world"
cut = _find_utf8_cut(encoded, 5)
assert 0 < cut <= 5
def test_cut_at_continuation_byte(self):
text = "你好世界"
encoded = text.encode("utf-8")
cut = _find_utf8_cut(encoded, 4)
decoded = encoded[:cut].decode("utf-8", errors="replace")
assert not any(c == "\ufffd" for c in decoded)
def test_cut_zero_becomes_one(self):
cut = _find_utf8_cut(b"test", 0)
assert cut == 1
class TestReceive:
@pytest.mark.asyncio
async def test_receive_is_empty_generator(self):
adapter = make_adapter()
items = [item async for item in adapter.receive()]
assert len(items) == 0
class TestSendNotConnected:
@pytest.mark.asyncio
async def test_send_returns_error_when_not_connected(self):
adapter = make_adapter()
identity = ChannelIdentity(
channel_id="twitch",
channel_type=ChannelType.TWITCH,
channel_user_id="",
channel_chat_id="#test_channel",
)
response = ChannelResponse(identity=identity, content="Hello")
result = await adapter.send(response)
assert isinstance(result, DeliveryResult)
assert result.success is False
assert result.error == "not_connected"
class TestHealthCheckDisconnected:
@pytest.mark.asyncio
async def test_health_check_reports_unhealthy_when_disconnected(self):
adapter = make_adapter()
status = await adapter.health_check()
assert status.status == "unhealthy"
class TestSendStreamChunk:
@pytest.mark.asyncio
async def test_stream_chunk_returns_error_when_not_connected(self):
adapter = make_adapter()
result = await adapter.send_stream_chunk("#test", "msg1", "chunk", finished=False)
assert isinstance(result, DeliveryResult)
assert result.success is False
class TestPreConnect:
@pytest.mark.asyncio
async def test_pre_connect_returns_empty_dict(self):
adapter = make_adapter()
result = await adapter.pre_connect()
assert result == {}
class TestBuildStreamIdentity:
def test_builds_identity_with_channel_info(self):
adapter = make_adapter()
identity = adapter._build_stream_identity("#test_channel", "msg_123")
assert identity.channel_id == "twitch"
assert identity.channel_type == ChannelType.TWITCH
assert identity.channel_chat_id == "#test_channel"
assert identity.channel_message_id == "msg_123"
class TestVerifyWebhookSignature:
@pytest.mark.asyncio
async def test_returns_true_by_default(self):
adapter = make_adapter()
result = await adapter.verify_webhook_signature({}, b"")
assert result is True
class TestFindUTF8CutBounds:
def test_byte_limit_within_encoded(self):
encoded = b"abc"
cut = _find_utf8_cut(encoded, 3)
assert cut == 3
def test_cut_at_start_byte(self):
text = "\u00e9\u00e0"
encoded = text.encode("utf-8")
cut = _find_utf8_cut(encoded, 3)
decoded = encoded[:cut].decode("utf-8", errors="replace")
assert not any(c == "\ufffd" for c in decoded)
def test_all_multibyte_characters(self):
text = "\u00e9\u00e0\u00fc\u2603"
encoded = text.encode("utf-8")
for i in range(2, len(encoded) + 1):
cut = _find_utf8_cut(encoded, i)
decoded = encoded[:cut].decode("utf-8", errors="replace")
assert not any(c == "\ufffd" for c in decoded), f"replacement char at byte_limit={i}, cut={cut}"
class TestSplitIRCTextEdgeCases:
def test_single_character(self):
chunks = TwitchAdapter._split_irc_text("A", "#chan")
assert len(chunks) == 1
assert chunks[0] == "A"
def test_empty_string(self):
chunks = TwitchAdapter._split_irc_text("", "#chan")
assert len(chunks) == 1
assert chunks[0] == ""
def test_very_long_channel_name(self):
long_channel = "#" + "x" * 400
text = "Hello world" * 100
chunks = TwitchAdapter._split_irc_text(text, long_channel)
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= 510
def test_split_with_emojis(self):
text = "Hello 😀🌍🎉" * 50
chunks = TwitchAdapter._split_irc_text(text, "#chan")
for chunk in chunks:
assert len(chunk.encode("utf-8")) <= 510
def test_split_cjk_characters(self):
text = "你好世界" * 100
chunks = TwitchAdapter._split_irc_text(text, "#chan")
assert "".join(chunks) == text
class TestDisconnectTaskTracking:
def test_reconnect_task_attr_initialized(self):
adapter = make_adapter()
assert adapter._reconnect_task is None
class TestReceiveExplicit:
@pytest.mark.asyncio
async def test_receive_returns_immediately(self):
adapter = make_adapter()
items = [item async for item in adapter.receive()]
assert items == []
class TestChatTypesCapability:
def test_chat_types_is_group(self):
assert TwitchAdapter.capabilities.chat_types == ["group"]
def test_reply_is_false(self):
assert TwitchAdapter.capabilities.reply is False
def test_edit_is_false(self):
assert TwitchAdapter.capabilities.edit is False
def test_reactions_is_false(self):
assert TwitchAdapter.capabilities.reactions is False
def test_polls_is_false(self):
assert TwitchAdapter.capabilities.polls is False
def test_block_streaming_is_true(self):
assert TwitchAdapter.capabilities.block_streaming is True
class TestDeduplicatorInit:
def test_deduplicator_initialized(self):
adapter = make_adapter()
assert adapter._deduplicator is not None
def test_deduplicator_empty_initially(self):
adapter = make_adapter()
assert len(adapter._deduplicator) == 0
class TestMaybeStripMarkdown:
def test_strips_markdown_by_default(self):
adapter = make_adapter()
result = adapter._maybe_strip_markdown("**bold** text")
assert "**" not in result
assert result == "bold text"
def test_no_strip_when_configured(self):
adapter = make_adapter({"client_id": "x", "access_token": "x", "bot_username": "x", "strip_markdown": False})
result = adapter._maybe_strip_markdown("**bold** text")
assert result == "**bold** text"
def test_empty_content_passthrough(self):
adapter = make_adapter()
assert adapter._maybe_strip_markdown("") == ""
class TestSelfMessageFilter:
def test_adapter_stores_bot_user_id_none_initially(self):
adapter = make_adapter()
assert adapter._bot_user_id is None
assert adapter._bot_username is None
class TestTokenPrefix:
def test_config_token_passthrough(self):
adapter = make_adapter()
assert adapter.config.get("access_token") == "test_token"