1. webhook路由新增记录请求源IP与请求头 2. 修复多个适配器单元测试:更新配置断言、补全async测试装饰器、重构测试调用逻辑 3. 新增Nostr适配器测试通用fixture 4. 更新NextcloudTalk签名相关测试与函数命名
2568 lines
94 KiB
Python
2568 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, desc="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):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({"threadBindings": {"idleHours": 0}})
|
|
mgr.bind("!room:example.org", "$root123")
|
|
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("auth" 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
|