1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
2570 lines
94 KiB
Python
2570 lines
94 KiB
Python
"""Unit tests for Matrix channel adapter.
|
|
|
|
Tests formatter, normalizer, thread_utils, session, crypto, probe, adapter,
|
|
and new modules: reaction_utils, encryption, concurrency, config_patch,
|
|
group_mentions, profile_sync, session (S7 target ID), probe (S14 device/backup).
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.matrix.adapter import MatrixAdapter
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
from yuxi.channels.adapters.matrix.session import MatrixSessionHelper
|
|
from yuxi.channels.adapters.matrix.thread_utils import is_thread_event
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelMessage,
|
|
ChannelResponse,
|
|
ChannelStatus,
|
|
ChannelType,
|
|
ChatType,
|
|
DeliveryResult,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
CHANNEL_ID = "matrix"
|
|
CHANNEL_TYPE = ChannelType.MATRIX
|
|
|
|
|
|
class TestFormatter:
|
|
def test_bold_text(self):
|
|
result = markdown_to_matrix_html("**hello**")
|
|
assert "<b>hello</b>" in result
|
|
|
|
def test_italic_text(self):
|
|
result = markdown_to_matrix_html("*hello*")
|
|
assert "<i>hello</i>" in result
|
|
|
|
def test_strikethrough_text(self):
|
|
result = markdown_to_matrix_html("~~hello~~")
|
|
assert "<del>hello</del>" in result
|
|
|
|
def test_inline_code(self):
|
|
result = markdown_to_matrix_html("`code`")
|
|
assert "<code>code</code>" in result
|
|
|
|
def test_code_block_escapes_html(self):
|
|
result = markdown_to_matrix_html("```\n<script>alert(1)</script>\n```")
|
|
assert "<pre><code>" in result
|
|
assert "<script>" in result
|
|
assert "alert(1)" in result
|
|
assert "</script>" in result
|
|
|
|
def test_link_renders_anchor(self):
|
|
result = markdown_to_matrix_html("[click](https://example.com)")
|
|
assert '<a href="https://example.com">' in result
|
|
|
|
def test_heading_renders_h_tags(self):
|
|
result = markdown_to_matrix_html("## Hello")
|
|
assert "<h2>Hello</h2>" in result
|
|
|
|
def test_horizontal_rule_renders_hr(self):
|
|
result = markdown_to_matrix_html("---")
|
|
assert "<hr>" in result
|
|
|
|
def test_unordered_list_renders_li(self):
|
|
result = markdown_to_matrix_html("- item")
|
|
assert "<li>item</li>" in result
|
|
|
|
def test_ordered_list_renders_li(self):
|
|
result = markdown_to_matrix_html("1. item")
|
|
assert "<li>item</li>" in result
|
|
|
|
def test_blockquote_renders_blockquote(self):
|
|
result = markdown_to_matrix_html("> quoted")
|
|
assert "<blockquote>quoted</blockquote>" in result
|
|
|
|
def test_strips_script_tag(self):
|
|
result = markdown_to_matrix_html("<script>alert(1)</script>")
|
|
assert "<script>" not in result
|
|
|
|
def test_strips_iframe_tag(self):
|
|
result = markdown_to_matrix_html("<iframe src='x'></iframe>")
|
|
assert "<iframe" not in result
|
|
|
|
def test_strips_svg_tag(self):
|
|
result = markdown_to_matrix_html("<svg></svg>")
|
|
assert "<svg" not in result
|
|
|
|
def test_strips_event_handler_attrs(self):
|
|
result = markdown_to_matrix_html("<img onerror=alert(1)>")
|
|
assert "onerror" not in result
|
|
|
|
def test_strips_javascript_href(self):
|
|
result = markdown_to_matrix_html('<a href="javascript:alert(1)">')
|
|
assert "javascript:" not in result
|
|
|
|
def test_strips_style_tag(self):
|
|
result = markdown_to_matrix_html("<style>body{}</style>")
|
|
assert "<style" not in result
|
|
|
|
def test_plain_text_unchanged(self):
|
|
result = markdown_to_matrix_html("hello world")
|
|
assert "hello world" in result
|
|
|
|
def test_underscore_bold(self):
|
|
result = markdown_to_matrix_html("__hello__")
|
|
assert "<b>hello</b>" in result
|
|
|
|
def test_underscore_italic(self):
|
|
result = markdown_to_matrix_html("_hello_")
|
|
assert "<i>hello</i>" in result
|
|
|
|
def test_combined_formatting(self):
|
|
result = markdown_to_matrix_html("**bold** and *italic* and ~~strike~~ and `code`")
|
|
assert "<b>bold</b>" in result
|
|
assert "<i>italic</i>" in result
|
|
assert "<del>strike</del>" in result
|
|
assert "<code>code</code>" in result
|
|
|
|
def test_multiline_text_uses_br(self):
|
|
result = markdown_to_matrix_html("line1\nline2")
|
|
assert "<br>" in result
|
|
|
|
|
|
class TestThreadUtils:
|
|
def test_thread_event_with_rel_type(self):
|
|
event = MagicMock()
|
|
event.source = {
|
|
"content": {
|
|
"m.relates_to": {
|
|
"rel_type": "m.thread",
|
|
"event_id": "$event123",
|
|
}
|
|
}
|
|
}
|
|
is_thread, root_id = is_thread_event(event)
|
|
assert is_thread is True
|
|
assert root_id == "$event123"
|
|
|
|
def test_non_thread_event(self):
|
|
event = MagicMock()
|
|
event.source = {
|
|
"content": {
|
|
"m.relates_to": {
|
|
"m.in_reply_to": {"event_id": "$event456"},
|
|
}
|
|
}
|
|
}
|
|
is_thread, root_id = is_thread_event(event)
|
|
assert is_thread is False
|
|
assert root_id is None
|
|
|
|
def test_empty_content(self):
|
|
event = MagicMock()
|
|
event.source = {}
|
|
is_thread, root_id = is_thread_event(event)
|
|
assert is_thread is False
|
|
assert root_id is None
|
|
|
|
|
|
class TestSessionHelper:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return MatrixAdapter({"user_id": "@bot:example.org"})
|
|
|
|
def test_resolve_chat_id_dm(self, adapter):
|
|
sid = adapter._session.resolve_chat_id("!room:example.org", is_dm=True)
|
|
assert sid == "dm_!room:example.org"
|
|
|
|
def test_resolve_chat_id_group(self, adapter):
|
|
sid = adapter._session.resolve_chat_id("!room:example.org", is_dm=False)
|
|
assert sid == "!room:example.org"
|
|
|
|
def test_resolve_thread_id_dm(self, adapter):
|
|
tid = adapter._session.resolve_thread_id("!room:example.org", is_dm=True, is_thread=False, root_event_id=None)
|
|
assert "dm" in tid
|
|
assert "main" in tid
|
|
|
|
def test_resolve_thread_id_group(self, adapter):
|
|
tid = adapter._session.resolve_thread_id("!room:example.org", is_dm=False, is_thread=False, root_event_id=None)
|
|
assert "room" in tid
|
|
|
|
def test_resolve_thread_id_with_thread(self, adapter):
|
|
tid = adapter._session.resolve_thread_id(
|
|
"!room:example.org", is_dm=False, is_thread=True, root_event_id="$evt", agent_config_id=5
|
|
)
|
|
assert "thread" in tid
|
|
assert "agent:5" in tid
|
|
assert "$evt" in tid
|
|
|
|
def test_resolve_chat_type_direct(self, adapter):
|
|
adapter._direct_rooms.add("!dm:example.org")
|
|
assert adapter._session.resolve_chat_type("!dm:example.org") == "direct"
|
|
|
|
def test_resolve_chat_type_group(self, adapter):
|
|
assert adapter._session.resolve_chat_type("!room:example.org") == "group"
|
|
|
|
|
|
class TestNormalizer:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({
|
|
"user_id": "@bot:example.org",
|
|
"homeserver": "https://example.org",
|
|
"requireMention": False,
|
|
})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
a._direct_rooms.add("!dm:example.org")
|
|
return a
|
|
|
|
def _make_text_event(self, sender="", body="", formatted_body=""):
|
|
from nio import RoomMessageText
|
|
|
|
event = MagicMock(spec=RoomMessageText)
|
|
event.sender = sender
|
|
event.body = body
|
|
event.event_id = "$evt001"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"msgtype": "m.text",
|
|
"body": body,
|
|
"formatted_body": formatted_body,
|
|
}
|
|
}
|
|
return event
|
|
|
|
def _make_image_event(self, sender=""):
|
|
from nio import RoomMessageImage
|
|
|
|
event = MagicMock(spec=RoomMessageImage)
|
|
event.sender = sender
|
|
event.body = "image.png"
|
|
event.event_id = "$evt002"
|
|
event.server_timestamp = 1700000001000
|
|
event.source = {
|
|
"content": {
|
|
"msgtype": "m.image",
|
|
"body": "image.png",
|
|
"url": "mxc://example.org/img001",
|
|
"info": {"mimetype": "image/png"},
|
|
}
|
|
}
|
|
return event
|
|
|
|
def test_dm_text_message(self, adapter):
|
|
event = self._make_text_event(sender="@user:example.org", body="Hello")
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.TEXT
|
|
assert msg.content == "Hello"
|
|
assert msg.identity.channel_user_id == "@user:example.org"
|
|
|
|
def test_skips_own_message(self, adapter):
|
|
event = self._make_text_event(sender="@bot:example.org", body="Hi")
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is None
|
|
|
|
def test_image_message(self, adapter):
|
|
event = self._make_image_event(sender="@user:example.org")
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.IMAGE
|
|
assert len(msg.attachments) == 1
|
|
assert msg.attachments[0].type == "image"
|
|
assert msg.attachments[0].url == "mxc://example.org/img001"
|
|
|
|
def test_bot_mention_from_formatted_body(self, adapter):
|
|
formatted = f'<a href="https://matrix.to/#/{adapter.user_id}">bot</a>'
|
|
event = self._make_text_event(sender="@user:example.org", body="hey", formatted_body=formatted)
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.mentions is not None
|
|
assert msg.mentions.is_bot_mentioned is True
|
|
assert msg.mentions.mentioned_user_ids
|
|
|
|
def test_extract_reply_to(self, adapter):
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "reply"
|
|
event.event_id = "$evt003"
|
|
event.server_timestamp = 1700000002000
|
|
event.source = {
|
|
"content": {
|
|
"body": "reply",
|
|
"m.relates_to": {
|
|
"m.in_reply_to": {"event_id": "$original"},
|
|
},
|
|
}
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.reply_to_message_id == "$original"
|
|
|
|
def test_plain_text_mention_extraction(self, adapter):
|
|
event = self._make_text_event(sender="@user:example.org", body="@alice:matrix.org hello")
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.mentions is not None
|
|
assert any("alice" in uid for uid in msg.mentions.mentioned_user_ids)
|
|
|
|
def test_content_type_audio(self, adapter):
|
|
from nio import RoomMessageAudio
|
|
|
|
event = MagicMock(spec=RoomMessageAudio)
|
|
event.sender = "@user:example.org"
|
|
event.body = "audio.mp3"
|
|
event.event_id = "$evt004"
|
|
event.server_timestamp = 1700000003000
|
|
event.source = {
|
|
"content": {
|
|
"msgtype": "m.audio",
|
|
"body": "audio.mp3",
|
|
"url": "mxc://example.org/audio001",
|
|
"info": {"mimetype": "audio/mp3"},
|
|
}
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.message_type == MessageType.AUDIO
|
|
assert msg.attachments[0].type == "audio"
|
|
|
|
def test_content_type_video(self, adapter):
|
|
from nio import RoomMessageVideo
|
|
|
|
event = MagicMock(spec=RoomMessageVideo)
|
|
event.sender = "@user:example.org"
|
|
event.body = "video.mp4"
|
|
event.event_id = "$evt005"
|
|
event.server_timestamp = 1700000004000
|
|
event.source = {
|
|
"content": {
|
|
"msgtype": "m.video",
|
|
"body": "video.mp4",
|
|
"url": "mxc://example.org/video001",
|
|
"info": {"mimetype": "video/mp4"},
|
|
}
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.message_type == MessageType.VIDEO
|
|
|
|
def test_content_type_file(self, adapter):
|
|
from nio import RoomMessageFile
|
|
|
|
event = MagicMock(spec=RoomMessageFile)
|
|
event.sender = "@user:example.org"
|
|
event.body = "doc.pdf"
|
|
event.event_id = "$evt006"
|
|
event.server_timestamp = 1700000005000
|
|
event.source = {
|
|
"content": {
|
|
"msgtype": "m.file",
|
|
"body": "doc.pdf",
|
|
"url": "mxc://example.org/file001",
|
|
"info": {"mimetype": "application/pdf"},
|
|
}
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.message_type == MessageType.FILE
|
|
assert msg.attachments[0].filename == "doc.pdf"
|
|
|
|
|
|
class TestAdapterStatus:
|
|
def test_initial_status_disconnected(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter.status == "disconnected"
|
|
|
|
def test_status_after_connect_starts_connecting(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter._status == ChannelStatus.DISCONNECTED
|
|
adapter._status = ChannelStatus.CONNECTING
|
|
assert adapter.status == "connecting"
|
|
|
|
def test_stream_state_initialized_empty(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter._stream_state == {}
|
|
|
|
def test_circuit_breaker_initialized(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter._circuit_breaker is not None
|
|
|
|
def test_disconnect_clears_stream_state(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
adapter._stream_state["key"] = 100
|
|
adapter._client = MagicMock()
|
|
adapter._client.close = AsyncMock()
|
|
import asyncio
|
|
|
|
asyncio.get_event_loop().run_until_complete(adapter.disconnect())
|
|
assert adapter._stream_state == {}
|
|
|
|
def test_user_id_returns_empty_when_no_client(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter.user_id == ""
|
|
|
|
def test_user_id_returns_client_user_id(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
adapter._client = MagicMock()
|
|
adapter._client.user_id = "@bot:example.org"
|
|
assert adapter.user_id == "@bot:example.org"
|
|
|
|
|
|
class TestAdapterSend:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_encodes_non_bytes(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
result = await adapter.send_media("!room:example.org", "image", "data_string")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_invokes_send_module(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
result = await adapter.send_reaction("!room:example.org", "$msg", "👍")
|
|
assert result.success is True
|
|
|
|
|
|
class TestAdapterRoomManagement:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invite_user_success(self, adapter):
|
|
mock_resp = MagicMock()
|
|
mock_resp.event_id = "$evt001"
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=DeliveryResult(success=True, message_id="$evt001"))
|
|
result = await adapter.invite_user("!room:example.org", "@user:example.org")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_kick_user_success(self, adapter):
|
|
mock_resp = MagicMock()
|
|
mock_resp.event_id = "$evt002"
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=DeliveryResult(success=True, message_id="$evt002"))
|
|
result = await adapter.kick_user("!room:example.org", "@user:example.org", "spam")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ban_user_success(self, adapter):
|
|
mock_resp = MagicMock()
|
|
mock_resp.event_id = "$evt003"
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=DeliveryResult(success=True, message_id="$evt003"))
|
|
result = await adapter.ban_user("!room:example.org", "@user:example.org", "abuse")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_leave_room_success(self, adapter):
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=DeliveryResult(success=True, message_id="$evt004"))
|
|
result = await adapter.leave_room("!room:example.org")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_join_room_success(self, adapter):
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=DeliveryResult(success=True, message_id="$evt005"))
|
|
result = await adapter.join_room("!room:example.org")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invite_user_no_client_returns_error(self, adapter):
|
|
adapter._client = None
|
|
result = await adapter.invite_user("!room", "@user")
|
|
assert result.success is False
|
|
assert result.error == "Client not initialized"
|
|
|
|
|
|
class TestAdapterTypingAndReceipt:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_indicator_calls_client(self, adapter):
|
|
adapter._client.room_typing = AsyncMock()
|
|
await adapter.send_typing_indicator("!room:example.org", typing=True)
|
|
adapter._client.room_typing.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_typing_indicator_no_client(self, adapter):
|
|
adapter._client = None
|
|
await adapter.send_typing_indicator("!room:example.org")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_receipt_calls_client(self, adapter):
|
|
adapter._client.room_read_markers = AsyncMock()
|
|
await adapter.send_read_receipt("!room:example.org", "$evt001")
|
|
adapter._client.room_read_markers.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_read_receipt_no_client(self, adapter):
|
|
adapter._client = None
|
|
await adapter.send_read_receipt("!room:example.org", "$evt001")
|
|
|
|
|
|
class TestAdapterNoClientReturnsError:
|
|
@pytest.fixture
|
|
def adapter_no_client(self):
|
|
return MatrixAdapter({"user_id": "@bot:example.org"})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_no_client(self, adapter_no_client):
|
|
identity = ChannelIdentity(
|
|
channel_id="matrix",
|
|
channel_type=ChannelType.MATRIX,
|
|
channel_user_id="",
|
|
channel_chat_id="!room",
|
|
channel_message_id="$msg",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="hello")
|
|
result = await adapter_no_client.send(response)
|
|
assert result.success is False
|
|
assert result.error == "Client not initialized"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_media_no_client(self, adapter_no_client):
|
|
result = await adapter_no_client.send_media("!room", "image", b"data")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_edit_message_no_client(self, adapter_no_client):
|
|
result = await adapter_no_client.edit_message("!room", "$msg", "edited")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_message_no_client(self, adapter_no_client):
|
|
result = await adapter_no_client.delete_message("!room", "$msg")
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_user_info_no_client(self, adapter_no_client):
|
|
result = await adapter_no_client.get_user_info("@user:matrix.org")
|
|
assert result == {}
|
|
|
|
|
|
class TestClassifySendError:
|
|
def test_auth_error_classified(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception()
|
|
exc.status_code = 403
|
|
exc.errcode = "M_UNKNOWN_TOKEN"
|
|
error_type, retry_after = classify_send_error(exc)
|
|
assert error_type == "auth"
|
|
assert retry_after is None
|
|
|
|
def test_forbidden_error_also_auth(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception()
|
|
exc.status_code = 403
|
|
exc.errcode = "M_FORBIDDEN"
|
|
error_type, _ = classify_send_error(exc)
|
|
assert error_type == "auth"
|
|
|
|
def test_rate_limit_classified(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception()
|
|
exc.status_code = 429
|
|
exc.retry_after_ms = 3000
|
|
error_type, retry_after = classify_send_error(exc)
|
|
assert error_type == "rate_limit"
|
|
assert retry_after == 3000
|
|
|
|
def test_rate_limit_default_backoff(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception()
|
|
exc.status_code = 429
|
|
error_type, retry_after = classify_send_error(exc)
|
|
assert error_type == "rate_limit"
|
|
assert retry_after == 5000
|
|
|
|
def test_unknown_error_is_other(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception("random error")
|
|
error_type, _ = classify_send_error(exc)
|
|
assert error_type == "other"
|
|
|
|
def test_no_status_code_is_other(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception()
|
|
error_type, _ = classify_send_error(exc)
|
|
assert error_type == "other"
|
|
|
|
|
|
class TestSendWithAuthRetry:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org", "password": "pwd"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_retries_on_auth_error(self, adapter):
|
|
from yuxi.channels.infra.circuit_breaker import CircuitBreakerOpenError
|
|
|
|
call_count = 0
|
|
|
|
async def _fail_then_ok(*_args, **_kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
exc = Exception("token expired")
|
|
exc.status_code = 403
|
|
exc.errcode = "M_UNKNOWN_TOKEN"
|
|
raise exc
|
|
return DeliveryResult(success=True, message_id="$ok")
|
|
|
|
adapter._circuit_breaker.call = _fail_then_ok
|
|
adapter._refresh_token_if_needed = AsyncMock(return_value=True)
|
|
|
|
result = await adapter._send_with_circuit(lambda: DeliveryResult(success=True))
|
|
assert result.success is True
|
|
assert call_count == 2
|
|
adapter._refresh_token_if_needed.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_fails_if_token_refresh_fails(self, adapter):
|
|
call_count = 0
|
|
|
|
async def _always_fail(*_args, **_kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
exc = Exception("token expired")
|
|
exc.status_code = 403
|
|
exc.errcode = "M_UNKNOWN_TOKEN"
|
|
raise exc
|
|
|
|
adapter._circuit_breaker.call = _always_fail
|
|
adapter._refresh_token_if_needed = AsyncMock(return_value=False)
|
|
|
|
result = await adapter._send_with_circuit(lambda: None)
|
|
assert result.success is False
|
|
assert "Token refresh failed" in result.error
|
|
|
|
|
|
class TestSendRateLimitRetry:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_retries_after_rate_limit_backoff(self, adapter):
|
|
call_count = 0
|
|
|
|
async def _fail_then_ok(*_args, **_kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
exc = Exception("rate limited")
|
|
exc.status_code = 429
|
|
exc.retry_after_ms = 10
|
|
raise exc
|
|
return DeliveryResult(success=True, message_id="$ok")
|
|
|
|
adapter._circuit_breaker.call = _fail_then_ok
|
|
|
|
result = await adapter._send_with_circuit(lambda: None)
|
|
assert result.success is True
|
|
assert call_count == 2
|
|
|
|
|
|
class TestMatrixAdapterCapabilities:
|
|
def test_chat_types_includes_thread(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert "thread" in adapter.capabilities.chat_types
|
|
|
|
def test_capabilities_includes_pin(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter.capabilities.pin is True
|
|
|
|
|
|
class TestAdapterReadMessages:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_messages_success(self, adapter):
|
|
mock_event = MagicMock()
|
|
mock_event.event_id = "$evt001"
|
|
mock_event.sender = "@user:example.org"
|
|
mock_event.origin_server_ts = 1700000000000
|
|
mock_event.source = {"content": {"body": "hello"}}
|
|
|
|
mock_resp = MagicMock()
|
|
mock_resp.chunk = [mock_event]
|
|
adapter._client.room_messages = AsyncMock(return_value=mock_resp)
|
|
|
|
messages = await adapter.read_messages("!room:example.org", limit=10)
|
|
assert len(messages) == 1
|
|
assert messages[0]["event_id"] == "$evt001"
|
|
assert messages[0]["sender"] == "@user:example.org"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_messages_no_client(self, adapter):
|
|
adapter._client = None
|
|
messages = await adapter.read_messages("!room:example.org")
|
|
assert messages == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_messages_error_returns_empty(self, adapter):
|
|
adapter._client.room_messages = AsyncMock(side_effect=Exception("network error"))
|
|
messages = await adapter.read_messages("!room:example.org")
|
|
assert messages == []
|
|
|
|
|
|
class TestAdapterChannelInfo:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_channel_info_success(self, adapter):
|
|
name_resp = MagicMock()
|
|
name_resp.name = "Test Room"
|
|
topic_resp = MagicMock()
|
|
topic_resp.topic = "A test room"
|
|
join_rules = MagicMock()
|
|
join_rules.join_rule = "public"
|
|
members_resp = MagicMock()
|
|
members_resp.members = [MagicMock(), MagicMock()]
|
|
|
|
adapter._client.room_get_state_event = AsyncMock(side_effect=[name_resp, topic_resp, join_rules])
|
|
adapter._client.joined_members = AsyncMock(return_value=members_resp)
|
|
adapter._joined_rooms.add("!room:example.org")
|
|
|
|
info = await adapter.get_channel_info("!room:example.org")
|
|
assert info["name"] == "Test Room"
|
|
assert info["topic"] == "A test room"
|
|
assert info["join_rule"] == "public"
|
|
assert info["member_count"] == 2
|
|
assert info["is_joined"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_channel_info_no_client(self, adapter):
|
|
adapter._client = None
|
|
info = await adapter.get_channel_info("!room:example.org")
|
|
assert info == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_channel_info_error_returns_partial(self, adapter):
|
|
adapter._client.room_get_state_event = AsyncMock(side_effect=Exception("error"))
|
|
info = await adapter.get_channel_info("!room:example.org")
|
|
assert "error" in info
|
|
|
|
|
|
class TestAdapterMemberInfo:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_member_info_success(self, adapter):
|
|
dn_resp = MagicMock()
|
|
dn_resp.displayname = "Alice"
|
|
avatar_resp = MagicMock()
|
|
avatar_resp.avatar_url = "mxc://example.org/avatar"
|
|
|
|
adapter._client.get_displayname = AsyncMock(return_value=dn_resp)
|
|
adapter._client.get_avatar = AsyncMock(return_value=avatar_resp)
|
|
|
|
info = await adapter.get_member_info("!room:example.org", "@alice:example.org")
|
|
assert info["display_name"] == "Alice"
|
|
assert info["avatar_url"] == "mxc://example.org/avatar"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_member_info_no_client(self, adapter):
|
|
adapter._client = None
|
|
info = await adapter.get_member_info("!room:example.org", "@alice:example.org")
|
|
assert info == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_member_info_error_returns_partial(self, adapter):
|
|
adapter._client.get_displayname = AsyncMock(side_effect=Exception("error"))
|
|
info = await adapter.get_member_info("!room:example.org", "@alice:example.org")
|
|
assert "error" in info
|
|
|
|
|
|
class TestAdapterSetProfile:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_profile_display_name(self, adapter):
|
|
adapter._client.set_displayname = AsyncMock()
|
|
result = await adapter.set_profile(display_name="MyBot")
|
|
assert result.success is True
|
|
adapter._client.set_displayname.assert_awaited_once_with("MyBot")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_profile_avatar(self, adapter):
|
|
adapter._client.set_avatar = AsyncMock()
|
|
result = await adapter.set_profile(avatar_url="mxc://example.org/avatar")
|
|
assert result.success is True
|
|
adapter._client.set_avatar.assert_awaited_once_with("mxc://example.org/avatar")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_profile_no_client(self, adapter):
|
|
adapter._client = None
|
|
result = await adapter.set_profile(display_name="MyBot")
|
|
assert result.success is False
|
|
assert result.error == "Client not initialized"
|
|
|
|
|
|
class TestAdapterPins:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_pins_success(self, adapter):
|
|
resp = MagicMock()
|
|
resp.pinned = ["$evt001", "$evt002"]
|
|
adapter._client.room_get_state_event = AsyncMock(return_value=resp)
|
|
|
|
pins = await adapter.list_pins("!room:example.org")
|
|
assert pins == ["$evt001", "$evt002"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_pins_empty(self, adapter):
|
|
adapter._client.room_get_state_event = AsyncMock(return_value=None)
|
|
pins = await adapter.list_pins("!room:example.org")
|
|
assert pins == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_pins_no_client(self, adapter):
|
|
adapter._client = None
|
|
pins = await adapter.list_pins("!room:example.org")
|
|
assert pins == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_message_success(self, adapter):
|
|
resp = MagicMock()
|
|
resp.pinned = ["$existing"]
|
|
adapter._client.room_get_state_event = AsyncMock(return_value=resp)
|
|
adapter._client.room_put_state = AsyncMock()
|
|
|
|
result = await adapter.pin_message("!room:example.org", "$new")
|
|
assert result.success is True
|
|
|
|
call_args = adapter._client.room_put_state.call_args
|
|
kwargs = call_args.kwargs if hasattr(call_args, "kwargs") else call_args[1] if len(call_args) > 1 else {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unpin_message_success(self, adapter):
|
|
resp = MagicMock()
|
|
resp.pinned = ["$evt001", "$evt002"]
|
|
adapter._client.room_get_state_event = AsyncMock(return_value=resp)
|
|
adapter._client.room_put_state = AsyncMock()
|
|
|
|
result = await adapter.unpin_message("!room:example.org", "$evt001")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_message_no_client(self, adapter):
|
|
adapter._client = None
|
|
result = await adapter.pin_message("!room:example.org", "$evt")
|
|
assert result.success is False
|
|
|
|
|
|
class TestAdapterNewlineChunkMode:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org", "chunkMode": "newline"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_newline_chunk_mode_used(self, adapter):
|
|
from yuxi.channels.adapters.matrix.adapter import _split_content
|
|
|
|
content = "line1\nline2\nline3\nline4"
|
|
chunks = _split_content(content, limit=10, mode="newline")
|
|
assert len(chunks) >= 1
|
|
|
|
def test_split_by_newlines_basic(self):
|
|
from yuxi.channels.adapters.matrix.adapter import _split_by_newlines
|
|
|
|
text = "aaa\nbbb\nccc"
|
|
encoded = text.encode("utf-8")
|
|
chunks = _split_by_newlines(encoded, limit=10)
|
|
assert len(chunks) >= 1
|
|
|
|
def test_split_by_length_basic(self):
|
|
from yuxi.channels.adapters.matrix.adapter import _split_by_length
|
|
|
|
encoded = b"abcdefghij"
|
|
chunks = _split_by_length(encoded, limit=3)
|
|
assert len(chunks) == 4
|
|
|
|
def test_split_content_short_no_split(self):
|
|
from yuxi.channels.adapters.matrix.adapter import _split_content
|
|
|
|
chunks = _split_content("hello", limit=65536, mode="length")
|
|
assert chunks == ["hello"]
|
|
|
|
def test_split_content_newline_mode(self):
|
|
from yuxi.channels.adapters.matrix.adapter import _split_content
|
|
|
|
content = "A" * 100 + "\n" + "B" * 100
|
|
chunks = _split_content(content, limit=50, mode="newline")
|
|
assert len(chunks) >= 2
|
|
|
|
|
|
class TestAdapterResponsePrefix:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org", "responsePrefix": "[Bot]"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_adds_prefix(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="matrix",
|
|
channel_type=ChannelType.MATRIX,
|
|
channel_user_id="",
|
|
channel_chat_id="!room",
|
|
channel_message_id="$msg",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="Hello")
|
|
await adapter.send(response)
|
|
|
|
call = adapter._circuit_breaker.call.await_args
|
|
assert call is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_no_prefix_when_empty_config(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
|
|
adapter.config["responsePrefix"] = ""
|
|
identity = ChannelIdentity(
|
|
channel_id="matrix",
|
|
channel_type=ChannelType.MATRIX,
|
|
channel_user_id="",
|
|
channel_chat_id="!room",
|
|
channel_message_id="$msg",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="Hello")
|
|
await adapter.send(response)
|
|
assert True
|
|
|
|
|
|
class TestAdapterMediaSizeCheck:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org", "mediaMaxMb": 1})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_too_large_rejected(self, adapter):
|
|
large_data = b"x" * (2 * 1024 * 1024)
|
|
result = await adapter.send_media("!room:example.org", "file", large_data)
|
|
assert result.success is False
|
|
assert "exceeds" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_within_limit_accepted(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
|
|
small_data = b"x" * 1024
|
|
result = await adapter.send_media("!room:example.org", "file", small_data)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_media_size_check_uses_default_when_not_configured(self, adapter):
|
|
adapter.config = {"user_id": "@bot:example.org"}
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=DeliveryResult(success=True, message_id="$evt"))
|
|
small_data = b"x" * 1024
|
|
result = await adapter.send_media("!room:example.org", "file", small_data)
|
|
assert result.success is True
|
|
|
|
|
|
class TestProbe:
|
|
@pytest.fixture
|
|
def mock_client(self):
|
|
from nio import AsyncClient
|
|
client = MagicMock(spec=AsyncClient)
|
|
client.homeserver = "https://example.org"
|
|
client.user_id = "@bot:example.org"
|
|
return client
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_matrix_success_with_whoami(self, mock_client):
|
|
from yuxi.channels.adapters.matrix.probe import probe_matrix
|
|
|
|
versions_resp = MagicMock()
|
|
versions_resp.versions = ["r0.6.1"]
|
|
whoami_resp = MagicMock()
|
|
whoami_resp.user_id = "@bot:example.org"
|
|
whoami_resp.device_id = "yuxi-bot"
|
|
whoami_resp.is_guest = False
|
|
|
|
mock_client.versions = AsyncMock(return_value=versions_resp)
|
|
mock_client.whoami = AsyncMock(return_value=whoami_resp)
|
|
|
|
result = await probe_matrix(mock_client, timeout_s=10)
|
|
assert result.status == "healthy"
|
|
assert "whoami" in result.metadata
|
|
assert result.metadata["whoami"]["user_id"] == "@bot:example.org"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_matrix_whoami_error_graceful(self, mock_client):
|
|
from yuxi.channels.adapters.matrix.probe import probe_matrix
|
|
|
|
versions_resp = MagicMock()
|
|
versions_resp.versions = ["r0.6.1"]
|
|
|
|
mock_client.versions = AsyncMock(return_value=versions_resp)
|
|
mock_client.whoami = AsyncMock(side_effect=Exception("whoami failed"))
|
|
|
|
result = await probe_matrix(mock_client, timeout_s=10)
|
|
assert result.status == "healthy"
|
|
assert "error" in result.metadata["whoami"]
|
|
|
|
|
|
class TestSendContentChunking:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
a = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
a._client = MagicMock()
|
|
a._client.user_id = "@bot:example.org"
|
|
return a
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_text_not_chunked(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="matrix",
|
|
channel_type=ChannelType.MATRIX,
|
|
channel_user_id="",
|
|
channel_chat_id="!room",
|
|
channel_message_id="$msg",
|
|
)
|
|
response = ChannelResponse(identity=identity, content="short")
|
|
result = await adapter.send(response)
|
|
assert result.success is True
|
|
assert adapter._circuit_breaker.call.await_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_text_is_chunked(self, adapter):
|
|
mock_result = DeliveryResult(success=True, message_id="$evt")
|
|
adapter._circuit_breaker.call = AsyncMock(return_value=mock_result)
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="matrix",
|
|
channel_type=ChannelType.MATRIX,
|
|
channel_user_id="",
|
|
channel_chat_id="!room",
|
|
channel_message_id="$msg",
|
|
)
|
|
long_text = "A" * 70000
|
|
response = ChannelResponse(identity=identity, content=long_text)
|
|
result = await adapter.send(response)
|
|
assert result.success is True
|
|
assert adapter._circuit_breaker.call.await_count >= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_chunk_failure_stops_early(self, adapter):
|
|
call_count = 0
|
|
|
|
async def _mock_call(func):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return DeliveryResult(success=True if call_count == 0 else False, error="chunk failed")
|
|
|
|
adapter._circuit_breaker.call = _mock_call
|
|
|
|
identity = ChannelIdentity(
|
|
channel_id="matrix",
|
|
channel_type=ChannelType.MATRIX,
|
|
channel_user_id="",
|
|
channel_chat_id="!room",
|
|
channel_message_id="$msg",
|
|
)
|
|
long_text = "A" * 70000
|
|
response = ChannelResponse(identity=identity, content=long_text)
|
|
result = await adapter.send(response)
|
|
assert result.success is False
|
|
assert "chunk failed" in result.error
|
|
|
|
|
|
class TestReactionUtils:
|
|
def test_build_reaction(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import build_reaction
|
|
|
|
result = build_reaction("👍", "$evt001")
|
|
relates_to = result["m.relates_to"]
|
|
assert relates_to["rel_type"] == "m.annotation"
|
|
assert relates_to["event_id"] == "$evt001"
|
|
assert relates_to["key"] == "👍"
|
|
|
|
def test_extract_reaction_valid(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import extract_reaction
|
|
|
|
event = MagicMock()
|
|
event.source = {
|
|
"content": {
|
|
"m.relates_to": {
|
|
"rel_type": "m.annotation",
|
|
"event_id": "$evt001",
|
|
"key": "❤️",
|
|
},
|
|
},
|
|
}
|
|
result = extract_reaction(event)
|
|
assert result is not None
|
|
assert result["target_event_id"] == "$evt001"
|
|
assert result["key"] == "❤️"
|
|
|
|
def test_extract_reaction_not_annotation(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import extract_reaction
|
|
|
|
event = MagicMock()
|
|
event.source = {
|
|
"content": {
|
|
"m.relates_to": {
|
|
"rel_type": "m.thread",
|
|
"event_id": "$evt002",
|
|
},
|
|
},
|
|
}
|
|
result = extract_reaction(event)
|
|
assert result is None
|
|
|
|
def test_summarize_reactions(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import summarize_reactions
|
|
|
|
events = [
|
|
{"sender": "@a:example.org", "key": "👍", "target_event_id": "$evt001"},
|
|
{"sender": "@b:example.org", "key": "👍", "target_event_id": "$evt001"},
|
|
{"sender": "@a:example.org", "key": "❤️", "target_event_id": "$evt001"},
|
|
{"sender": "@c:example.org", "key": "👍", "target_event_id": "$evt002"},
|
|
]
|
|
result = summarize_reactions(events)
|
|
assert "$evt001" in result
|
|
assert result["$evt001"]["unique_reactors"] == 2
|
|
assert len(result["$evt001"]["keys"]) == 2
|
|
|
|
def test_select_reaction_exact_match(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import select_reaction
|
|
|
|
available = [{"key": "👍"}, {"key": "❤️"}, {"key": "🎉"}]
|
|
result = select_reaction(available, "❤️")
|
|
assert result is not None
|
|
assert result["key"] == "❤️"
|
|
|
|
def test_select_reaction_fallback_to_first(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import select_reaction
|
|
|
|
available = [{"key": "👍"}, {"key": "❤️"}]
|
|
result = select_reaction(available, "😊")
|
|
assert result is not None
|
|
assert result["key"] == "👍"
|
|
|
|
def test_select_reaction_empty_list(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import select_reaction
|
|
|
|
result = select_reaction([], "👍")
|
|
assert result is None
|
|
|
|
|
|
class TestEncryption:
|
|
def test_resolve_encryption_config_path(self):
|
|
from yuxi.channels.adapters.matrix.encryption import resolve_encryption_config_path
|
|
|
|
path = resolve_encryption_config_path("/data", "https://example.org")
|
|
assert "matrix_crypto_store" in path
|
|
assert "example.org" in path
|
|
|
|
def test_format_encryption_unavailable_error(self):
|
|
from yuxi.channels.adapters.matrix.encryption import format_encryption_unavailable_error
|
|
|
|
msg = format_encryption_unavailable_error(
|
|
{"homeserver": "https://example.org", "crypto_store_dir": "/tmp"}
|
|
)
|
|
assert "E2EE is not configured" in msg
|
|
assert "/tmp" in msg
|
|
|
|
def test_check_crypto_store_ready_not_configured(self):
|
|
from yuxi.channels.adapters.matrix.encryption import check_crypto_store_ready
|
|
|
|
result = check_crypto_store_ready("")
|
|
assert result["status"] == "not_configured"
|
|
|
|
def test_check_crypto_store_ready_unavailable(self):
|
|
from yuxi.channels.adapters.matrix.encryption import check_crypto_store_ready
|
|
|
|
result = check_crypto_store_ready("/nonexistent/path/12345")
|
|
assert result["status"] == "unavailable"
|
|
|
|
def test_crypto_bootstrap_guide(self):
|
|
from yuxi.channels.adapters.matrix.encryption import get_crypto_bootstrap_guide
|
|
|
|
guide = get_crypto_bootstrap_guide()
|
|
assert "E2EE Bootstrap" in guide
|
|
|
|
|
|
class TestConcurrency:
|
|
@pytest.mark.asyncio
|
|
async def test_async_lock_acquire_release(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import AsyncLock
|
|
|
|
lock = AsyncLock()
|
|
assert not lock.locked
|
|
async with lock:
|
|
assert lock.locked
|
|
assert not lock.locked
|
|
|
|
def test_startup_serial_lock_acquire(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import StartupSerialLock
|
|
|
|
lock = StartupSerialLock()
|
|
assert lock.acquire_startup() is True
|
|
assert lock.is_starting is True
|
|
assert lock.acquire_startup() is False
|
|
|
|
def test_startup_serial_lock_release(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import StartupSerialLock
|
|
|
|
lock = StartupSerialLock()
|
|
lock.acquire_startup()
|
|
lock.release_startup()
|
|
assert lock.is_starting is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_account_data_write_queue_enqueue_drain(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import AccountDataWriteQueue
|
|
|
|
queue = AccountDataWriteQueue()
|
|
await queue.enqueue("m.direct", {"@user:example.org": ["!room:example.org"]})
|
|
items = await queue.drain()
|
|
assert len(items) == 1
|
|
assert items[0][0] == "m.direct"
|
|
|
|
|
|
class TestConfigPatch:
|
|
def test_matrix_account_patch_from_dict(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch
|
|
|
|
patch = MatrixAccountPatch.from_dict({"display_name": "NewBot", "sync_timeout_ms": 60000})
|
|
assert patch.display_name == "NewBot"
|
|
assert patch.sync_timeout_ms == 60000
|
|
assert patch.has_changes is True
|
|
|
|
def test_matrix_account_patch_empty(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch
|
|
|
|
patch = MatrixAccountPatch()
|
|
assert patch.has_changes is False
|
|
|
|
def test_apply_patch_updates_config(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import apply_patch, MatrixAccountPatch
|
|
|
|
config = {"user_id": "@bot:example.org", "display_name": "OldBot"}
|
|
patch = MatrixAccountPatch.from_dict({"display_name": "NewBot", "sync_timeout_ms": 60000})
|
|
result = apply_patch(config, patch)
|
|
assert result["display_name"] == "NewBot"
|
|
assert result["sync_timeout_ms"] == 60000
|
|
assert result["user_id"] == "@bot:example.org"
|
|
|
|
def test_merge_patches(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import merge_patches, MatrixAccountPatch
|
|
|
|
base = MatrixAccountPatch(display_name="A", sync_timeout_ms=30000)
|
|
overlay = MatrixAccountPatch(display_name="B")
|
|
merged = merge_patches(base, overlay)
|
|
assert merged.display_name == "B"
|
|
assert merged.sync_timeout_ms == 30000
|
|
|
|
def test_diff_config(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import diff_config
|
|
|
|
old = {"a": 1, "b": 2}
|
|
new = {"a": 1, "b": 3, "c": 4}
|
|
diff = diff_config(old, new)
|
|
assert "b" in diff
|
|
assert "c" in diff
|
|
assert "a" not in diff
|
|
|
|
def test_to_dict_extra_fields(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch
|
|
|
|
patch = MatrixAccountPatch.from_dict({"custom_key": "custom_value"})
|
|
assert patch.extra == {"custom_key": "custom_value"}
|
|
d = patch.to_dict()
|
|
assert "custom_key" in d
|
|
|
|
|
|
class TestGroupMentions:
|
|
def test_require_mention_from_room_config(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
config = {
|
|
"requireMention": True,
|
|
"rooms": {"!room1:example.org": {"requireMention": False}},
|
|
}
|
|
strategy = GroupMentionStrategy(config)
|
|
assert strategy.should_require_mention("!room1:example.org") is False
|
|
assert strategy.should_require_mention("!room2:example.org") is True
|
|
|
|
def test_is_at_mentioned(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
config = {"user_id": "@bot:example.org"}
|
|
strategy = GroupMentionStrategy(config)
|
|
result = strategy.is_at_mentioned(
|
|
"@bot:example.org",
|
|
"!room:example.org",
|
|
"hello @bot:example.org",
|
|
'<a href="https://matrix.to/#/@bot:example.org">bot</a>',
|
|
)
|
|
assert result is True
|
|
|
|
def test_evaluate_room_policy_no_mention_required(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
config = {"requireMention": False}
|
|
strategy = GroupMentionStrategy(config)
|
|
result = strategy.evaluate_room_policy("!room:example.org", "@user:example.org", "hello", "")
|
|
assert result is True
|
|
|
|
def test_build_room_mention_config(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import build_room_mention_config
|
|
|
|
result = build_room_mention_config(
|
|
"!room:example.org",
|
|
require_mention=True,
|
|
allowed_roles=["admin"],
|
|
mention_exempt_users=["@admin:example.org"],
|
|
)
|
|
assert "!room:example.org" in result
|
|
assert result["!room:example.org"]["requireMention"] is True
|
|
assert "admin" in result["!room:example.org"]["allowedRoles"]
|
|
|
|
|
|
class TestProfileSync:
|
|
def test_resolve_avatar_url_mxc_passthrough(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_avatar_url
|
|
|
|
result = resolve_avatar_url("mxc://example.org/avatar001")
|
|
assert result == "mxc://example.org/avatar001"
|
|
|
|
def test_resolve_avatar_url_http_to_mxc(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_avatar_url
|
|
|
|
result = resolve_avatar_url("https://cdn.example.org/avatar.png", "example.org")
|
|
assert result is not None
|
|
assert result.startswith("mxc://")
|
|
|
|
def test_resolve_avatar_url_empty(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_avatar_url
|
|
|
|
assert resolve_avatar_url("") is None
|
|
assert resolve_avatar_url("", "example.org") is None
|
|
|
|
def test_resolve_display_name(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_display_name
|
|
|
|
assert resolve_display_name({"displayname": "Alice"}) == "Alice"
|
|
assert resolve_display_name({"user_id": "@alice:example.org"}) == "alice"
|
|
assert resolve_display_name({}, "@fallback:example.org") == "fallback"
|
|
|
|
def test_compare_profiles_detects_changes(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import compare_profiles
|
|
|
|
current = {"displayname": "Old", "avatar_url": "mxc://old"}
|
|
incoming = {"displayname": "New", "avatar_url": "mxc://new"}
|
|
changes = compare_profiles(current, incoming)
|
|
assert len(changes) == 2
|
|
assert changes["displayname"] == ("Old", "New")
|
|
|
|
def test_mxc_to_http_conversion(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import mxc_to_http
|
|
|
|
url = mxc_to_http("mxc://example.org/abc123", "https://matrix.example.org")
|
|
assert url.startswith("https://")
|
|
assert "abc123" in url
|
|
assert "example.org" in url
|
|
|
|
|
|
class TestSessionTargetID:
|
|
@pytest.fixture
|
|
def adapter(self):
|
|
return MatrixAdapter({"user_id": "@bot:example.org"})
|
|
|
|
def test_parse_target_id_matrix_uri(self, adapter):
|
|
result = adapter._session.parse_target_id("matrix:!room:example.org")
|
|
assert result["type"] == "matrix_uri"
|
|
assert result["value"] == "!room:example.org"
|
|
|
|
def test_parse_target_id_room_prefix(self, adapter):
|
|
result = adapter._session.parse_target_id("room:!room:example.org")
|
|
assert result["type"] == "room_id"
|
|
|
|
def test_parse_target_id_user_prefix(self, adapter):
|
|
result = adapter._session.parse_target_id("user:@alice:example.org")
|
|
assert result["type"] == "user_id"
|
|
|
|
def test_parse_target_id_mxid(self, adapter):
|
|
result = adapter._session.parse_target_id("@alice:example.org")
|
|
assert result["type"] == "user_id"
|
|
|
|
def test_parse_target_id_room_id(self, adapter):
|
|
result = adapter._session.parse_target_id("!room:example.org")
|
|
assert result["type"] == "room_id"
|
|
|
|
def test_parse_target_id_room_alias(self, adapter):
|
|
result = adapter._session.parse_target_id("#general:example.org")
|
|
assert result["type"] == "room_alias"
|
|
|
|
def test_parse_target_id_unknown(self, adapter):
|
|
result = adapter._session.parse_target_id("random_string")
|
|
assert result["type"] == "unknown"
|
|
|
|
def test_resolve_target_to_room_room_id(self, adapter):
|
|
result = adapter._session.resolve_target_to_room("!room:example.org")
|
|
assert result == "!room:example.org"
|
|
|
|
def test_resolve_session_metadata(self, adapter):
|
|
event_source = {
|
|
"content": {
|
|
"m.relates_to": {
|
|
"rel_type": "m.thread",
|
|
"event_id": "$root",
|
|
},
|
|
"info": {"mimetype": "image/png", "size": 1024},
|
|
},
|
|
}
|
|
metadata = adapter._session.resolve_session_metadata("!room:example.org", event_source)
|
|
assert metadata["is_thread"] is True
|
|
assert metadata["thread_root"] == "$root"
|
|
assert metadata["mimetype"] == "image/png"
|
|
|
|
def test_resolve_participant_metadata(self, adapter):
|
|
adapter._client = MagicMock()
|
|
adapter._client.user_id = "@bot:example.org"
|
|
metadata = adapter._session.resolve_participant_metadata(
|
|
"@bot:example.org", "MyBot", "mxc://avatar"
|
|
)
|
|
assert metadata["is_bot_self"] is True
|
|
assert metadata["display_name"] == "MyBot"
|
|
|
|
|
|
class TestProbeDeviceBackup:
|
|
def test_describe_backup_state_known(self):
|
|
from yuxi.channels.adapters.matrix.probe import describe_backup_state
|
|
|
|
assert describe_backup_state(1) == "keys_backed_up_and_verified"
|
|
assert describe_backup_state(5) == "backup_disabled"
|
|
assert describe_backup_state(99) == "unknown_state_99"
|
|
|
|
def test_is_backup_healthy(self):
|
|
from yuxi.channels.adapters.matrix.probe import is_backup_healthy
|
|
|
|
assert is_backup_healthy(1) is True
|
|
assert is_backup_healthy(2) is True
|
|
assert is_backup_healthy(0) is False
|
|
assert is_backup_healthy(4) is False
|
|
|
|
def test_check_device_health_known_prefix(self):
|
|
from yuxi.channels.adapters.matrix.probe import _check_device_health
|
|
|
|
result = _check_device_health({"device_id": "yuxi-bot-abc123"})
|
|
assert result["status"] == "healthy"
|
|
assert result["is_known_prefix"] == "true"
|
|
|
|
def test_check_device_health_unrecognized(self):
|
|
from yuxi.channels.adapters.matrix.probe import _check_device_health
|
|
|
|
result = _check_device_health({"device_id": "random-device"})
|
|
assert result["status"] == "unrecognized"
|
|
|
|
def test_check_device_health_no_device_id(self):
|
|
from yuxi.channels.adapters.matrix.probe import _check_device_health
|
|
|
|
result = _check_device_health({})
|
|
assert result["status"] == "unknown"
|
|
|
|
|
|
class TestAdapterEnhancedStreamingModes:
|
|
def test_streaming_modes_include_lane_and_reasoning(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert "lane" in adapter.streaming_modes
|
|
assert "reasoning" in adapter.streaming_modes
|
|
assert "off" in adapter.streaming_modes
|
|
assert "block" in adapter.streaming_modes
|
|
|
|
def test_capabilities_include_lane_streaming(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter.capabilities.lane_streaming is True
|
|
assert adapter.capabilities.reasoning_streaming is True
|
|
assert adapter.capabilities.block_streaming is True
|
|
|
|
def test_mention_strategy_initialized(self):
|
|
adapter = MatrixAdapter({"user_id": "@bot:example.org"})
|
|
assert adapter._mention_strategy is not None
|
|
|
|
|
|
class TestPolls:
|
|
def test_extract_poll_start_valid(self):
|
|
from yuxi.channels.adapters.matrix.polls import extract_poll_start
|
|
|
|
event_source = {
|
|
"content": {
|
|
"org.matrix.msc3381.poll.start": {
|
|
"question": {"body": "What?", "kind": "open", "msgtype": "m.text"},
|
|
"answers": [
|
|
{"id": "answer_0", "org.matrix.msc1767.text": "A"},
|
|
{"id": "answer_1", "org.matrix.msc1767.text": "B"},
|
|
],
|
|
"max_selections": 1,
|
|
},
|
|
},
|
|
}
|
|
result = extract_poll_start(event_source)
|
|
assert result is not None
|
|
assert result["question"] == "What?"
|
|
assert len(result["answers"]) == 2
|
|
assert result["answers"][0]["text"] == "A"
|
|
|
|
def test_extract_poll_start_missing(self):
|
|
from yuxi.channels.adapters.matrix.polls import extract_poll_start
|
|
|
|
assert extract_poll_start({"content": {}}) is None
|
|
|
|
def test_extract_poll_response_valid(self):
|
|
from yuxi.channels.adapters.matrix.polls import extract_poll_response
|
|
|
|
event_source = {
|
|
"content": {
|
|
"m.relates_to": {"rel_type": "m.reference", "event_id": "$poll"},
|
|
"org.matrix.msc3381.poll.response": {"answers": ["answer_0"]},
|
|
},
|
|
}
|
|
result = extract_poll_response(event_source)
|
|
assert result is not None
|
|
assert result["event_id"] == "$poll"
|
|
assert result["answers"] == ["answer_0"]
|
|
|
|
def test_extract_poll_response_missing(self):
|
|
from yuxi.channels.adapters.matrix.polls import extract_poll_response
|
|
|
|
assert extract_poll_response({"content": {}}) is None
|
|
|
|
def test_is_poll_end_true(self):
|
|
from yuxi.channels.adapters.matrix.polls import is_poll_end
|
|
|
|
assert is_poll_end({"content": {"org.matrix.msc3381.poll.end": {}}}) is True
|
|
|
|
def test_is_poll_end_false(self):
|
|
from yuxi.channels.adapters.matrix.polls import is_poll_end
|
|
|
|
assert is_poll_end({"content": {}}) is False
|
|
|
|
def test_summarize_poll_responses(self):
|
|
from yuxi.channels.adapters.matrix.polls import summarize_poll_responses
|
|
|
|
responses = [
|
|
{"event_id": "$poll", "poll_response": {"answers": ["answer_0"]}},
|
|
{"event_id": "$poll", "poll_response": {"answers": ["answer_0"]}},
|
|
{"event_id": "$poll", "poll_response": {"answers": ["answer_1"]}},
|
|
]
|
|
tally = summarize_poll_responses(responses)
|
|
assert "$poll" in tally
|
|
assert tally["$poll"]["answer_0"] == 2
|
|
assert tally["$poll"]["answer_1"] == 1
|
|
|
|
|
|
class TestLocation:
|
|
def test_parse_location_event_with_msgtype(self):
|
|
from yuxi.channels.adapters.matrix.location import parse_location_event
|
|
|
|
event_source = {
|
|
"content": {
|
|
"msgtype": "m.location",
|
|
"body": "Meet us here",
|
|
"geo_uri": "geo:51.5007,-0.1246",
|
|
},
|
|
}
|
|
result = parse_location_event(event_source)
|
|
assert result is not None
|
|
assert result["type"] == "location"
|
|
assert "Meet us here" in result["text"]
|
|
assert "geo:51.5007,-0.1246" in result["geo_uri"]
|
|
|
|
def test_parse_location_event_geo_uri_only(self):
|
|
from yuxi.channels.adapters.matrix.location import parse_location_event
|
|
|
|
assert parse_location_event({"content": {"geo_uri": "geo:40.0,-74.0"}}) is not None
|
|
|
|
def test_parse_location_event_no_location(self):
|
|
from yuxi.channels.adapters.matrix.location import parse_location_event
|
|
|
|
assert parse_location_event({"content": {"body": "hello"}}) is None
|
|
|
|
def test_extract_geo_coordinates_valid(self):
|
|
from yuxi.channels.adapters.matrix.location import extract_geo_coordinates
|
|
|
|
result = extract_geo_coordinates("geo:51.5007,-0.1246")
|
|
assert result is not None
|
|
assert result["lat"] == 51.5007
|
|
assert result["lon"] == -0.1246
|
|
|
|
def test_extract_geo_coordinates_invalid(self):
|
|
from yuxi.channels.adapters.matrix.location import extract_geo_coordinates
|
|
|
|
assert extract_geo_coordinates("invalid") is None
|
|
assert extract_geo_coordinates("notgeo:") is None
|
|
|
|
def test_build_outbound_location(self):
|
|
from yuxi.channels.adapters.matrix.location import build_outbound_location
|
|
|
|
result = build_outbound_location("Office", 37.7749, -122.4194, description="SF Office")
|
|
assert result["msgtype"] == "m.location"
|
|
assert result["geo_uri"] == "geo:37.7749,-122.4194"
|
|
|
|
def test_format_location_text_with_body(self):
|
|
from yuxi.channels.adapters.matrix.location import format_location_text
|
|
|
|
text = format_location_text("Park", "geo:1,2")
|
|
assert "[位置]" in text
|
|
|
|
|
|
class TestSticker:
|
|
def test_is_sticker_event_true(self):
|
|
from yuxi.channels.adapters.matrix.sticker import is_sticker_event
|
|
|
|
assert is_sticker_event({"content": {"msgtype": "m.sticker"}}) is True
|
|
|
|
def test_is_sticker_event_false(self):
|
|
from yuxi.channels.adapters.matrix.sticker import is_sticker_event
|
|
|
|
assert is_sticker_event({"content": {"msgtype": "m.text"}}) is False
|
|
|
|
def test_parse_sticker_valid(self):
|
|
from yuxi.channels.adapters.matrix.sticker import parse_sticker
|
|
|
|
event_source = {
|
|
"content": {
|
|
"msgtype": "m.sticker",
|
|
"body": "cute",
|
|
"url": "mxc://example.org/sticker1",
|
|
"info": {"mimetype": "image/png", "w": 128, "h": 128},
|
|
},
|
|
}
|
|
result = parse_sticker(event_source)
|
|
assert result is not None
|
|
assert result["type"] == "sticker"
|
|
assert result["url"] == "mxc://example.org/sticker1"
|
|
assert result["width"] == 128
|
|
|
|
def test_build_outbound_sticker(self):
|
|
from yuxi.channels.adapters.matrix.sticker import build_outbound_sticker
|
|
|
|
result = build_outbound_sticker("mxc://example.org/st1", body="hello", width=64, height=64)
|
|
assert result["msgtype"] == "m.sticker"
|
|
assert result["url"] == "mxc://example.org/st1"
|
|
assert result["info"]["w"] == 64
|
|
|
|
|
|
class TestSSRFGuard:
|
|
def test_is_private_host_localhost(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("localhost") is True
|
|
assert SSRFGuard.is_private_host("127.0.0.1") is True
|
|
|
|
def test_is_private_host_private_range(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("192.168.1.1") is True
|
|
assert SSRFGuard.is_private_host("10.0.0.1") is True
|
|
|
|
def test_is_private_host_public(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("93.184.216.34") is False
|
|
assert SSRFGuard.is_private_host("matrix.org") is False
|
|
|
|
def test_is_private_host_empty(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("") is False
|
|
|
|
def test_validate_url_blocks_private(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
error = SSRFGuard.validate_url("http://127.0.0.1:8008")
|
|
assert error is not None
|
|
assert "private" in error.lower()
|
|
|
|
def test_validate_url_allows_private_when_enabled(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
error = SSRFGuard.validate_url("http://192.168.1.1", allow_private=True)
|
|
assert error is None
|
|
|
|
def test_validate_url_public_passes(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
error = SSRFGuard.validate_url("https://matrix.org")
|
|
assert error is None
|
|
|
|
def test_validate_url_empty_returns_error(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.validate_url("") is not None
|
|
|
|
|
|
class TestSyncTokenStore:
|
|
def test_save_and_load_token(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_store import SyncTokenStore
|
|
|
|
store = SyncTokenStore(str(tmp_path), "@user:example.org")
|
|
store.save("s12345")
|
|
assert store.load() == "s12345"
|
|
|
|
def test_load_nonexistent_returns_none(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_store import SyncTokenStore
|
|
|
|
store = SyncTokenStore(str(tmp_path / "nonexistent"), "@user:example.org")
|
|
assert store.load() is None
|
|
|
|
def test_clear_removes_token(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_store import SyncTokenStore
|
|
|
|
store = SyncTokenStore(str(tmp_path), "@user:example.org")
|
|
store.save("s12345")
|
|
store.clear()
|
|
assert store.load() is None
|
|
|
|
|
|
class TestMultiAccountManager:
|
|
def test_load_accounts(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {
|
|
"accounts": {
|
|
"bot1": {"user_id": "@bot1:example.org", "homeserver": "https://example1.org", "isDefault": True},
|
|
"bot2": {"user_id": "@bot2:example.org", "homeserver": "https://example2.org"},
|
|
},
|
|
}
|
|
mgr = MultiAccountManager(config)
|
|
assert mgr.account_count == 2
|
|
assert mgr.default_account.user_id == "@bot1:example.org"
|
|
|
|
def test_list_accounts(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {
|
|
"accounts": {"bot1": {"user_id": "@bot1:example.org"}},
|
|
}
|
|
mgr = MultiAccountManager(config)
|
|
accounts = mgr.list_accounts()
|
|
assert len(accounts) == 1
|
|
assert accounts[0].user_id == "@bot1:example.org"
|
|
|
|
def test_get_account(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {
|
|
"accounts": {
|
|
"bot1": {"user_id": "@bot1:example.org"},
|
|
"bot2": {"user_id": "@bot2:example.org"},
|
|
},
|
|
"defaultAccount": "bot2",
|
|
}
|
|
mgr = MultiAccountManager(config)
|
|
acc = mgr.get("bot2")
|
|
assert acc.user_id == "@bot2:example.org"
|
|
|
|
def test_select_account(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {
|
|
"accounts": {
|
|
"bot1": {"user_id": "@bot1:example.org"},
|
|
"bot2": {"user_id": "@bot2:example.org"},
|
|
},
|
|
}
|
|
mgr = MultiAccountManager(config)
|
|
assert mgr.select_account("bot2") is True
|
|
assert mgr.default_account_id == "bot2"
|
|
|
|
def test_select_nonexistent_account(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {"accounts": {"bot1": {"user_id": "@bot1:example.org"}}}
|
|
mgr = MultiAccountManager(config)
|
|
assert mgr.select_account("nonexistent") is False
|
|
|
|
def test_account_to_dict(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MatrixAccount
|
|
|
|
acc = MatrixAccount("bot1", {"user_id": "@bot1:example.org", "homeserver": "https://example.org"})
|
|
d = acc.to_dict()
|
|
assert d["user_id"] == "@bot1:example.org"
|
|
assert d["account_id"] == "bot1"
|
|
|
|
|
|
class TestThreadBindingManager:
|
|
def test_bind_creates_entry(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({})
|
|
binding = mgr.bind("!room:example.org", "$root123", session_key="sess1")
|
|
assert binding.room_id == "!room:example.org"
|
|
assert binding.session_key == "sess1"
|
|
assert mgr.binding_count == 1
|
|
|
|
def test_bind_same_thread_returns_existing(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({})
|
|
b1 = mgr.bind("!room:example.org", "$root123")
|
|
b2 = mgr.bind("!room:example.org", "$root123")
|
|
assert b1 is b2
|
|
|
|
def test_unbind_removes_entry(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({})
|
|
mgr.bind("!room:example.org", "$root123")
|
|
mgr.unbind("!room:example.org", "$root123")
|
|
assert mgr.binding_count == 0
|
|
|
|
def test_get_nonexistent_returns_none(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({})
|
|
assert mgr.get("!room:example.org", "$nonexistent") is None
|
|
|
|
def test_idle_timeout_expiry(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({"threadBindings": {"idleHours": 0.001}})
|
|
binding = mgr.bind("!room:example.org", "$root123")
|
|
binding.last_activity = time.monotonic() - 100
|
|
expired = mgr.check_timeouts()
|
|
assert len(expired) == 1
|
|
|
|
|
|
class TestDirectRoom:
|
|
def test_promote_room_to_direct_returns_status(self):
|
|
mock_client = MagicMock()
|
|
mock_adata = MagicMock()
|
|
mock_adata.content = {}
|
|
mock_client.get_account_data = AsyncMock(return_value=mock_adata)
|
|
mock_client.set_account_data = AsyncMock()
|
|
|
|
adapter = MatrixAdapter({})
|
|
adapter._client = mock_client
|
|
|
|
import asyncio
|
|
result = asyncio.get_event_loop().run_until_complete(
|
|
adapter.promote_room_to_direct("!room:example.org", "@alice:example.org")
|
|
)
|
|
assert result["status"] == "promoted"
|
|
|
|
def test_demote_direct_room(self):
|
|
mock_client = MagicMock()
|
|
mock_adata = MagicMock()
|
|
mock_adata.content = {"@alice:example.org": ["!room:example.org"]}
|
|
mock_client.get_account_data = AsyncMock(return_value=mock_adata)
|
|
mock_client.set_account_data = AsyncMock()
|
|
|
|
adapter = MatrixAdapter({})
|
|
adapter._client = mock_client
|
|
|
|
import asyncio
|
|
result = asyncio.get_event_loop().run_until_complete(
|
|
adapter.demote_direct_room("!room:example.org", "@alice:example.org")
|
|
)
|
|
assert result["status"] == "demoted"
|
|
|
|
|
|
class TestEnvVars:
|
|
def test_resolve_env_vars_populates_homeserver(self):
|
|
import os
|
|
from yuxi.channels.adapters.matrix.env_vars import resolve_env_vars
|
|
|
|
os.environ["MATRIX_HOMESERVER"] = "https://custom.example.org"
|
|
result = resolve_env_vars({"user_id": "@bot:example.org"})
|
|
assert result["homeserver"] == "https://custom.example.org"
|
|
del os.environ["MATRIX_HOMESERVER"]
|
|
|
|
def test_resolve_env_vars_does_not_override_existing(self):
|
|
import os
|
|
from yuxi.channels.adapters.matrix.env_vars import resolve_env_vars
|
|
|
|
result = resolve_env_vars({"homeserver": "https://explicit.example.org"})
|
|
assert result["homeserver"] == "https://explicit.example.org"
|
|
|
|
def test_get_env_var_descriptions(self):
|
|
from yuxi.channels.adapters.matrix.env_vars import get_env_var_descriptions
|
|
|
|
descs = get_env_var_descriptions()
|
|
assert "MATRIX_HOMESERVER" in descs
|
|
assert "MATRIX_ACCESS_TOKEN" in descs
|
|
|
|
def test_coerce_bool_values(self):
|
|
from yuxi.channels.adapters.matrix.env_vars import _coerce_type
|
|
|
|
assert _coerce_type("true", True) is True
|
|
assert _coerce_type("false", True) is False
|
|
assert _coerce_type("1", True) is True
|
|
assert _coerce_type("yes", True) is True
|
|
|
|
def test_coerce_int_values(self):
|
|
from yuxi.channels.adapters.matrix.env_vars import _coerce_type
|
|
|
|
assert _coerce_type("42", 0) == 42
|
|
assert _coerce_type("invalid", 0) == "invalid"
|
|
|
|
|
|
class TestConfigSchema:
|
|
def test_validate_config_required_user_id(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({})
|
|
assert any(e["field"] == "user_id" for e in errors)
|
|
|
|
def test_validate_config_missing_auth(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({"user_id": "@bot:example.org"})
|
|
assert any("access_token" in e["field"] or "password" in e["field"] for e in errors)
|
|
|
|
def test_validate_config_valid(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"homeserver": "https://example.org",
|
|
"access_token": "token123",
|
|
})
|
|
assert len(errors) == 0
|
|
|
|
def test_get_default_config(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import get_default_config
|
|
|
|
defaults = get_default_config()
|
|
assert defaults["homeserver"] == "https://matrix.org"
|
|
assert defaults["device_id"] == "yuxi-bot"
|
|
|
|
def test_get_config_schema(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import get_config_schema
|
|
|
|
schema = get_config_schema()
|
|
assert "homeserver" in schema
|
|
assert "user_id" in schema
|
|
assert schema["user_id"]["required"] is True
|
|
|
|
|
|
class TestCredentials:
|
|
def test_matrix_credentials_to_dict(self):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials
|
|
|
|
creds = MatrixCredentials(
|
|
user_id="@bot:example.org",
|
|
homeserver="https://example.org",
|
|
access_token="tok123",
|
|
device_id="dev001",
|
|
)
|
|
d = creds.to_dict()
|
|
assert d["user_id"] == "@bot:example.org"
|
|
assert d["access_token"] == "tok123"
|
|
|
|
def test_matrix_credentials_is_valid(self):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials
|
|
|
|
valid = MatrixCredentials(user_id="@b:a", homeserver="https://a", access_token="t")
|
|
assert valid.is_valid is True
|
|
|
|
invalid = MatrixCredentials()
|
|
assert invalid.is_valid is False
|
|
|
|
def test_credential_store_save_and_load(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials, CredentialStore
|
|
|
|
store = CredentialStore(base_dir=str(tmp_path))
|
|
creds = MatrixCredentials(user_id="@bot:a", homeserver="https://a", access_token="tok")
|
|
store.save(creds)
|
|
|
|
loaded = store.load("@bot:a")
|
|
assert loaded is not None
|
|
assert loaded.user_id == "@bot:a"
|
|
|
|
def test_credential_store_load_nonexistent(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.credentials import CredentialStore
|
|
|
|
store = CredentialStore(base_dir=str(tmp_path))
|
|
assert store.load("nonexistent") is None
|
|
|
|
def test_credential_store_delete(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials, CredentialStore
|
|
|
|
store = CredentialStore(base_dir=str(tmp_path))
|
|
creds = MatrixCredentials(user_id="@bot:a", homeserver="https://a", access_token="tok")
|
|
store.save(creds)
|
|
store.delete("@bot:a")
|
|
assert store.load("@bot:a") is None
|
|
|
|
|
|
class TestSyncStateMachine:
|
|
def test_initial_state_is_prepared(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
assert sm.state == SyncState.PREPARED
|
|
|
|
def test_transition_to_syncing(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
assert sm.to_healthy() is True
|
|
assert sm.state == SyncState.SYNCING
|
|
assert sm.is_healthy is True
|
|
|
|
def test_transition_to_error(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
assert sm.to_error("connection lost") is True
|
|
assert sm.state == SyncState.ERROR
|
|
assert sm.last_error == "connection lost"
|
|
assert sm.is_healthy is False
|
|
|
|
def test_transition_to_reconnecting(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
sm.to_error()
|
|
assert sm.to_reconnecting() is True
|
|
assert sm.state == SyncState.RECONNECTING
|
|
|
|
def test_retry_count_increments(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
for _ in range(5):
|
|
sm.to_error()
|
|
sm.to_reconnecting()
|
|
sm.to_healthy()
|
|
assert sm.retry_count == 0
|
|
|
|
|
|
class TestSentMessageCache:
|
|
def test_add_and_get(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import SentMessageCache
|
|
|
|
cache = SentMessageCache()
|
|
cache.add("!room:example.org", "hash1", "$evt001")
|
|
assert cache.get("!room:example.org", "hash1") == "$evt001"
|
|
|
|
def test_get_content_hash_match(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import SentMessageCache, hash_content
|
|
|
|
cache = SentMessageCache()
|
|
content_hash = hash_content("hello world")
|
|
cache.add("!room:example.org", content_hash, "$evt002")
|
|
event_id = cache.get_event_id_for_content("!room:example.org", "hello world")
|
|
assert event_id == "$evt002"
|
|
|
|
def test_remove_by_event_id(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import SentMessageCache
|
|
|
|
cache = SentMessageCache()
|
|
cache.add("!room:example.org", "hash1", "$evt001")
|
|
cache.remove("!room:example.org", "$evt001")
|
|
assert cache.get("!room:example.org", "hash1") is None
|
|
|
|
def test_clear_room(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import SentMessageCache
|
|
|
|
cache = SentMessageCache()
|
|
cache.add("!room1:example.org", "hash1", "$evt001")
|
|
cache.add("!room2:example.org", "hash2", "$evt002")
|
|
cache.clear_room("!room1:example.org")
|
|
assert cache.get("!room1:example.org", "hash1") is None
|
|
assert cache.get("!room2:example.org", "hash2") == "$evt002"
|
|
|
|
|
|
class TestResponseSizeLimiter:
|
|
def test_check_within_limit(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import ResponseSizeLimiter
|
|
|
|
limiter = ResponseSizeLimiter(max_bytes=1000)
|
|
ok, _ = limiter.check(500)
|
|
assert ok is True
|
|
|
|
def test_check_exceeds_limit(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import ResponseSizeLimiter
|
|
|
|
limiter = ResponseSizeLimiter(max_bytes=1000)
|
|
ok, msg = limiter.check(2000)
|
|
assert ok is False
|
|
assert "exceeds" in msg
|
|
|
|
|
|
class TestVoice:
|
|
def test_is_voice_message_with_msc3245(self):
|
|
from yuxi.channels.adapters.matrix.voice import is_voice_message
|
|
|
|
event_source = {
|
|
"content": {"msgtype": "m.audio", "org.matrix.msc3245.voice": {}},
|
|
}
|
|
assert is_voice_message(event_source) is True
|
|
|
|
def test_is_voice_message_with_msc1767(self):
|
|
from yuxi.channels.adapters.matrix.voice import is_voice_message
|
|
|
|
event_source = {
|
|
"content": {"msgtype": "m.audio", "org.matrix.msc1767.audio": {"duration": 5000}},
|
|
}
|
|
assert is_voice_message(event_source) is True
|
|
|
|
def test_is_voice_message_not_audio(self):
|
|
from yuxi.channels.adapters.matrix.voice import is_voice_message
|
|
|
|
event_source = {"content": {"msgtype": "m.text"}}
|
|
assert is_voice_message(event_source) is False
|
|
|
|
def test_extract_audio_info(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.voice import extract_audio_info
|
|
|
|
event_source = {
|
|
"content": {
|
|
"msgtype": "m.audio",
|
|
"info": {"duration": 15000, "mimetype": "audio/ogg", "size": 32768},
|
|
"org.matrix.msc3245.voice": {},
|
|
},
|
|
}
|
|
info = extract_audio_info(event_source)
|
|
assert info["duration_ms"] == 15000
|
|
assert info["mime_type"] == "audio/ogg"
|
|
assert info["is_voice"] is True
|
|
|
|
|
|
class TestMediaMeta:
|
|
def test_extract_duration_bytes_ogg(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import extract_duration_bytes
|
|
|
|
duration = extract_duration_bytes(b"OggS\x00\x02" + b"\x00" * 1000, "audio/ogg")
|
|
assert duration >= 0
|
|
|
|
def test_extract_duration_bytes_mp3(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import extract_duration_bytes
|
|
|
|
duration = extract_duration_bytes(b"\xff\xfb\x90" + b"\x00" * 1000, "audio/mp3")
|
|
assert duration >= 0
|
|
|
|
|
|
class TestSetupWizard:
|
|
def test_build_steps_returns_list(self):
|
|
from yuxi.channels.adapters.matrix.setup_wizard import MatrixSetupWizard
|
|
|
|
wizard = MatrixSetupWizard()
|
|
steps = wizard.build_steps()
|
|
assert len(steps) >= 5
|
|
assert steps[0]["id"] == "homeserver"
|
|
|
|
def test_advance_advances_step(self):
|
|
from yuxi.channels.adapters.matrix.setup_wizard import MatrixSetupWizard
|
|
|
|
wizard = MatrixSetupWizard()
|
|
assert wizard.advance() is True
|
|
|
|
def test_go_back_returns_false_at_start(self):
|
|
from yuxi.channels.adapters.matrix.setup_wizard import MatrixSetupWizard
|
|
|
|
wizard = MatrixSetupWizard()
|
|
assert wizard.go_back() is False
|
|
|
|
def test_to_config_produces_valid_config(self):
|
|
from yuxi.channels.adapters.matrix.setup_wizard import MatrixSetupWizard
|
|
|
|
wizard = MatrixSetupWizard()
|
|
wizard.set_answer("homeserver", "https://example.org")
|
|
wizard.set_answer("user_id", "@bot:example.org")
|
|
wizard.set_answer("auth_method", "password")
|
|
wizard.set_answer("credential", "secret")
|
|
|
|
config = wizard.to_config()
|
|
assert config["homeserver"] == "https://example.org"
|
|
assert config["user_id"] == "@bot:example.org"
|
|
assert config["password"] == "secret"
|
|
|
|
|
|
class TestCommands:
|
|
def test_detect_slash_command_help(self):
|
|
from yuxi.channels.adapters.matrix.commands import detect_slash_command
|
|
|
|
result = detect_slash_command("/help")
|
|
assert result is not None
|
|
assert result["command"] == "/help"
|
|
assert result["handler"] == "show_help"
|
|
|
|
def test_detect_slash_command_unknown(self):
|
|
from yuxi.channels.adapters.matrix.commands import detect_slash_command
|
|
|
|
assert detect_slash_command("/unknown") is None
|
|
|
|
def test_detect_slash_command_not_command(self):
|
|
from yuxi.channels.adapters.matrix.commands import detect_slash_command
|
|
|
|
assert detect_slash_command("hello world") is None
|
|
|
|
def test_is_slash_command_true(self):
|
|
from yuxi.channels.adapters.matrix.commands import is_slash_command
|
|
|
|
assert is_slash_command("/help") is True
|
|
|
|
def test_is_slash_command_false(self):
|
|
from yuxi.channels.adapters.matrix.commands import is_slash_command
|
|
|
|
assert is_slash_command("help") is False
|
|
|
|
def test_format_help_text(self):
|
|
from yuxi.channels.adapters.matrix.commands import format_help_text
|
|
|
|
text = format_help_text()
|
|
assert "/help" in text
|
|
assert "/new" in text
|
|
|
|
def test_get_command_list(self):
|
|
from yuxi.channels.adapters.matrix.commands import get_command_list
|
|
|
|
cmds = get_command_list()
|
|
assert any(c["command"] == "/help" for c in cmds)
|
|
|
|
def test_detect_slash_command_with_args(self):
|
|
from yuxi.channels.adapters.matrix.commands import detect_slash_command
|
|
|
|
result = detect_slash_command("/proxy spawn bind_to_thread")
|
|
assert result is not None
|
|
assert result["command"] == "/proxy"
|
|
assert result["args"].get("spawn") == "spawn"
|
|
|
|
|
|
class TestSubagentHooks:
|
|
def test_build_subagent_event(self):
|
|
from yuxi.channels.adapters.matrix.subagent_hooks import build_subagent_event
|
|
|
|
event = build_subagent_event("spawning", "!room:example.org", "$root", subagent_id="sub1")
|
|
assert event["phase"] == "spawning"
|
|
assert event["subagent_id"] == "sub1"
|
|
|
|
def test_subagent_lifecycle_on_and_trigger(self):
|
|
from yuxi.channels.adapters.matrix.subagent_hooks import SubagentLifecycle
|
|
|
|
results = []
|
|
async def cb(event):
|
|
results.append(event["phase"])
|
|
|
|
lifecycle = SubagentLifecycle()
|
|
lifecycle.on("spawning", cb)
|
|
|
|
import asyncio
|
|
asyncio.get_event_loop().run_until_complete(
|
|
lifecycle.trigger("spawning", "!room:example.org", "$root")
|
|
)
|
|
assert len(results) == 1
|
|
assert results[0] == "spawning"
|
|
|
|
|
|
class TestExecApprovals:
|
|
def test_is_approver_no_list(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({"execApprovals": {"enabled": "true"}})
|
|
assert mgr.is_approver("@anyone:example.org") is True
|
|
|
|
def test_is_approver_with_list(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({
|
|
"execApprovals": {"enabled": "true", "approvers": ["@admin:example.org"]},
|
|
})
|
|
assert mgr.is_approver("@admin:example.org") is True
|
|
assert mgr.is_approver("@user:example.org") is False
|
|
|
|
def test_requires_approval_when_enabled(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({"execApprovals": {"enabled": "true"}})
|
|
assert mgr.requires_approval("!room:example.org", "rm -rf /") is True
|
|
|
|
def test_requires_approval_when_disabled(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({"execApprovals": {"enabled": "false"}})
|
|
assert mgr.requires_approval("!room:example.org", "ls") is False
|
|
|
|
def test_create_request_and_handle_approval(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({"execApprovals": {"enabled": "true"}})
|
|
request = mgr.create_request("req1", "dangerous cmd", "!room:example.org", "@user:example.org")
|
|
assert request.status == "pending"
|
|
|
|
result = mgr.handle_reaction("@admin:example.org", "\u2705", "req1")
|
|
assert result == "allow-once"
|
|
assert request.status == "approved"
|
|
|
|
def test_handle_reaction_deny(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({"execApprovals": {"enabled": "true"}})
|
|
mgr.create_request("req1", "cmd", "!room:example.org", "@user:example.org")
|
|
|
|
result = mgr.handle_reaction("@admin:example.org", "\u274c", "req1")
|
|
assert result == "deny"
|
|
|
|
def test_get_pending(self):
|
|
from yuxi.channels.adapters.matrix.exec_approvals import ExecApprovalManager
|
|
|
|
mgr = ExecApprovalManager({"execApprovals": {"enabled": "true"}})
|
|
mgr.create_request("req1", "cmd1", "!room:example.org", "@user:example.org")
|
|
mgr.create_request("req2", "cmd2", "!room:example.org", "@user:example.org")
|
|
assert len(mgr.get_pending()) == 2
|
|
|
|
|
|
class TestACPBindings:
|
|
def test_create_binding(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBindingManager
|
|
|
|
mgr = ACPBindingManager({})
|
|
binding = mgr.create_binding("!room:example.org", "$root", session_id="acp1")
|
|
assert binding.acp_session_id == "acp1"
|
|
assert not binding.bound
|
|
|
|
def test_get_nonexistent_returns_none(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBindingManager
|
|
|
|
mgr = ACPBindingManager({})
|
|
assert mgr.get("!room:example.org", "$nonexistent") is None
|
|
|
|
def test_remove_binding(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBindingManager
|
|
|
|
mgr = ACPBindingManager({})
|
|
mgr.create_binding("!room:example.org", "$root")
|
|
assert mgr.remove("!room:example.org", "$root") is True
|
|
assert mgr.get("!room:example.org", "$root") is None
|
|
|
|
def test_list_bindings(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBindingManager
|
|
|
|
mgr = ACPBindingManager({})
|
|
mgr.create_binding("!room1:example.org", "$root1")
|
|
mgr.create_binding("!room2:example.org", "$root2")
|
|
assert len(mgr.list_bindings()) == 2
|
|
|
|
|
|
class TestDraftStream:
|
|
def test_remove_draft(self):
|
|
from yuxi.channels.adapters.matrix.draft_stream import (
|
|
get_or_create_draft,
|
|
remove_draft,
|
|
)
|
|
|
|
draft = get_or_create_draft("!room:example.org")
|
|
assert draft.room_id == "!room:example.org"
|
|
assert not draft.is_active
|
|
|
|
remove_draft("!room:example.org")
|
|
draft2 = get_or_create_draft("!room:example.org")
|
|
assert draft is not draft2
|
|
|
|
|
|
class TestConfigUIHints:
|
|
def test_get_config_hints_returns_dict(self):
|
|
from yuxi.channels.adapters.matrix.config_ui_hints import get_config_hints
|
|
|
|
hints = get_config_hints()
|
|
assert len(hints) > 10
|
|
assert "homeserver" in hints
|
|
assert hints["homeserver"]["group"] == "connection"
|
|
|
|
def test_get_hint_specific_key(self):
|
|
from yuxi.channels.adapters.matrix.config_ui_hints import get_hint
|
|
|
|
hint = get_hint("homeserver")
|
|
assert hint is not None
|
|
assert hint["label"] == "Homeserver URL"
|
|
|
|
def test_get_hint_nonexistent(self):
|
|
from yuxi.channels.adapters.matrix.config_ui_hints import get_hint
|
|
|
|
assert get_hint("nonexistent") is None
|
|
|
|
def test_get_group_hints(self):
|
|
from yuxi.channels.adapters.matrix.config_ui_hints import get_group_hints
|
|
|
|
hints = get_group_hints("security")
|
|
assert len(hints) > 0
|
|
assert all(v["group"] == "security" for v in hints.values())
|
|
|
|
def test_get_groups(self):
|
|
from yuxi.channels.adapters.matrix.config_ui_hints import get_groups
|
|
|
|
groups = get_groups()
|
|
assert "connection" in groups
|
|
assert "security" in groups
|
|
assert groups["connection"] == "连接设置"
|
|
|
|
|
|
class TestMatrixCLI:
|
|
def test_dispatch_no_args(self):
|
|
from yuxi.channels.adapters.matrix.cli import MatrixCLI
|
|
|
|
cli = MatrixCLI()
|
|
result = cli.dispatch([])
|
|
assert result["status"] == "error"
|
|
|
|
def test_dispatch_unknown_command(self):
|
|
from yuxi.channels.adapters.matrix.cli import MatrixCLI
|
|
|
|
cli = MatrixCLI()
|
|
result = cli.dispatch(["unknown"])
|
|
assert result["status"] == "error"
|
|
|
|
def test_dispatch_matrix_status(self):
|
|
from yuxi.channels.adapters.matrix.cli import MatrixCLI
|
|
|
|
cli = MatrixCLI()
|
|
result = cli.dispatch(["matrix", "status"])
|
|
assert result["status"] == "ok"
|
|
assert result["command"] == "matrix status"
|
|
|
|
def test_dispatch_matrix_account_list(self):
|
|
from yuxi.channels.adapters.matrix.cli import MatrixCLI
|
|
|
|
cli = MatrixCLI()
|
|
result = cli.dispatch(["matrix", "account", "list"])
|
|
assert result["status"] == "ok"
|
|
assert "account" in result["command"]
|
|
|
|
def test_format_cli_help(self):
|
|
from yuxi.channels.adapters.matrix.cli import format_cli_help
|
|
|
|
help_text = format_cli_help()
|
|
assert "matrix account" in help_text
|
|
assert "matrix doctor" in help_text
|
|
|
|
|
|
class TestMatrixDoctor:
|
|
@pytest.mark.asyncio
|
|
async def test_diagnose_valid_config(self):
|
|
from yuxi.channels.adapters.matrix.doctor import MatrixDoctor
|
|
|
|
doctor = MatrixDoctor({
|
|
"user_id": "@bot:example.org",
|
|
"homeserver": "https://example.org",
|
|
"access_token": "tok123",
|
|
})
|
|
issues = await doctor.diagnose()
|
|
assert len(issues) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diagnose_missing_auth(self):
|
|
from yuxi.channels.adapters.matrix.doctor import MatrixDoctor
|
|
|
|
doctor = MatrixDoctor({"user_id": "@bot:example.org"})
|
|
issues = await doctor.diagnose()
|
|
assert len(issues) > 0
|
|
assert any(i["category"] == "auth" for i in issues)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_diagnose_missing_user_id(self):
|
|
from yuxi.channels.adapters.matrix.doctor import MatrixDoctor
|
|
|
|
doctor = MatrixDoctor({"access_token": "tok123"})
|
|
issues = await doctor.diagnose()
|
|
assert any(i["severity"] == "error" for i in issues)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repair_creates_crypto_dir(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.doctor import MatrixDoctor
|
|
|
|
crypto_dir = str(tmp_path / "crypto")
|
|
doctor = MatrixDoctor({
|
|
"user_id": "@bot:example.org",
|
|
"homeserver": "https://example.org",
|
|
"access_token": "tok123",
|
|
"crypto_store_dir": crypto_dir,
|
|
})
|
|
repairs = await doctor.repair()
|
|
assert any(r["category"] == "encryption" for r in repairs)
|
|
|
|
def test_get_summary(self):
|
|
from yuxi.channels.adapters.matrix.doctor import MatrixDoctor
|
|
|
|
doctor = MatrixDoctor({"user_id": "@bot:example.org"})
|
|
doctor._issues = [
|
|
{"severity": "error", "message": "test"},
|
|
{"severity": "warning", "message": "test"},
|
|
{"severity": "info", "message": "test"},
|
|
]
|
|
summary = doctor.get_summary()
|
|
assert summary["error"] == 1
|
|
assert summary["warning"] == 1
|
|
assert summary["info"] == 1
|
|
|
|
def test_has_critical_issues(self):
|
|
from yuxi.channels.adapters.matrix.doctor import MatrixDoctor
|
|
|
|
doctor = MatrixDoctor({"user_id": "@bot:example.org"})
|
|
doctor._issues = [{"severity": "error", "message": "critical"}]
|
|
assert doctor.has_critical_issues is True
|
|
|
|
doctor._issues = [{"severity": "info", "message": "ok"}]
|
|
assert doctor.has_critical_issues is False
|