新增了Twitch、Telegram、Discord、Slack、Mattermost、WeChat、Zalo等多渠道的单元测试用例,覆盖了令牌处理、速率限制、消息去重、会话解析、格式转换、安全策略等模块 同时在测试配置中添加了测试用的OpenAI API密钥环境变量
340 lines
11 KiB
Python
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" |