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重连相关测试
2408 lines
92 KiB
Python
2408 lines
92 KiB
Python
"""Comprehensive unit tests for Matrix channel adapter — edge cases and uncovered modules.
|
|
|
|
Covers: dedupe, security, allowlist, rooms_config, crypto, auto_join,
|
|
directory, polls async, config_schema (pattern/enum/min/max/type),
|
|
accounts remaining, credentials encrypted, draft_stream, media_meta,
|
|
profile_sync edges, sync_state edges, sent_cache edges, SSRFGuard edges,
|
|
concurrency edges, formatter edges, normalizer edges, voice edges,
|
|
reaction_utils edges, send edges, location/sticker edges.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.adapters.matrix.adapter import MatrixAdapter
|
|
from yuxi.channels.models import (
|
|
ChannelIdentity,
|
|
ChannelResponse,
|
|
ChannelType,
|
|
DeliveryResult,
|
|
EventType,
|
|
MessageType,
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# EventDeduplicator
|
|
# =============================================================================
|
|
|
|
class TestEventDeduplicator:
|
|
def test_not_duplicate_first_seen(self):
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
d = EventDeduplicator()
|
|
assert d.is_duplicate("$evt1") is False
|
|
|
|
def test_duplicate_second_time(self):
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
d = EventDeduplicator()
|
|
d.is_duplicate("$evt1")
|
|
assert d.is_duplicate("$evt1") is True
|
|
|
|
def test_different_events_distinct(self):
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
d = EventDeduplicator()
|
|
d.is_duplicate("$evt1")
|
|
assert d.is_duplicate("$evt2") is False
|
|
|
|
def test_clear_removes_all(self):
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
d = EventDeduplicator()
|
|
d.is_duplicate("$evt1")
|
|
d.clear()
|
|
assert len(d) == 0
|
|
assert d.is_duplicate("$evt1") is False
|
|
|
|
def test_len_reflects_stored_count(self):
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
d = EventDeduplicator()
|
|
d.is_duplicate("$a")
|
|
d.is_duplicate("$b")
|
|
assert len(d) == 2
|
|
|
|
def test_ttl_expiration_evicts_old_entries(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
|
|
d = EventDeduplicator(ttl_seconds=0.01, max_size=100)
|
|
d.is_duplicate("$evt1")
|
|
time.sleep(0.02)
|
|
assert d.is_duplicate("$evt1") is False
|
|
|
|
def test_max_size_evicts_oldest(self):
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
d = EventDeduplicator(max_size=2, ttl_seconds=3600)
|
|
d.is_duplicate("$a")
|
|
d.is_duplicate("$b")
|
|
d.is_duplicate("$c")
|
|
assert len(d) <= 2
|
|
assert d.is_duplicate("$a") is False
|
|
|
|
def test_duplicate_moves_to_end(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.dedupe import EventDeduplicator
|
|
|
|
d = EventDeduplicator(max_size=3, ttl_seconds=3600)
|
|
d.is_duplicate("$a")
|
|
d.is_duplicate("$b")
|
|
d.is_duplicate("$a")
|
|
d.is_duplicate("$c")
|
|
assert len(d) == 3
|
|
assert d.is_duplicate("$b") is True
|
|
|
|
|
|
# =============================================================================
|
|
# MatrixSecurityPolicy
|
|
# =============================================================================
|
|
|
|
class TestMatrixSecurityPolicy:
|
|
def test_dm_policy_open(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"dm": {"policy": "open"}})
|
|
assert p.evaluate_dm_access("@anyone:example.org") is True
|
|
|
|
def test_dm_policy_disabled(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"dm": {"policy": "disabled"}})
|
|
assert p.evaluate_dm_access("@anyone:example.org") is False
|
|
|
|
def test_dm_policy_allowlist_match(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"dm": {"policy": "allowlist", "allowFrom": ["@friend:example.org"]}})
|
|
assert p.evaluate_dm_access("@friend:example.org") is True
|
|
|
|
def test_dm_policy_allowlist_no_match(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"dm": {"policy": "allowlist", "allowFrom": ["@friend:example.org"]}})
|
|
assert p.evaluate_dm_access("@stranger:example.org") is False
|
|
|
|
def test_dm_policy_pairing_match(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"dm": {"policy": "pairing", "allowFrom": ["@partner:example.org"]}})
|
|
assert p.evaluate_dm_access("@partner:example.org") is True
|
|
|
|
def test_dm_policy_pairing_no_match(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"dm": {"policy": "pairing", "allowFrom": ["@partner:example.org"]}})
|
|
assert p.evaluate_dm_access("@other:example.org") is False
|
|
|
|
def test_group_policy_open(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"groupPolicy": "open"})
|
|
assert p.evaluate_group_access("@anyone:example.org", "!room:example.org") is True
|
|
|
|
def test_group_policy_disabled(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"groupPolicy": "disabled"})
|
|
assert p.evaluate_group_access("@anyone:example.org", "!room:example.org") is False
|
|
|
|
def test_group_policy_allowlist_match(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"groupPolicy": "allowlist", "groupAllowFrom": ["@member:example.org"]})
|
|
assert p.evaluate_group_access("@member:example.org", "!room:example.org") is True
|
|
|
|
def test_group_room_users_match(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"rooms": {"!room:example.org": {"users": ["@room_member:example.org"]}}})
|
|
assert p.evaluate_group_access("@room_member:example.org", "!room:example.org") is True
|
|
assert p.evaluate_group_access("@other:example.org", "!room:example.org") is False
|
|
|
|
def test_allowlist_only_mode(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({
|
|
"allowlistOnly": True,
|
|
"dm": {"policy": "open", "allowFrom": ["@approved:example.org"]},
|
|
})
|
|
assert p.evaluate_dm_access("@approved:example.org") is True
|
|
assert p.evaluate_dm_access("@other:example.org") is False
|
|
|
|
def test_should_skip_bot_true(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"allowBots": False})
|
|
assert p.should_skip_bot("@[bot]helper:example.org") is True
|
|
|
|
def test_should_skip_bot_false_when_allowed(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"allowBots": True})
|
|
assert p.should_skip_bot("@[bot]helper:example.org") is False
|
|
|
|
def test_should_skip_bot_none_config(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({})
|
|
assert p.should_skip_bot("@[bot]helper:example.org") is False
|
|
|
|
def test_should_skip_bot_not_bot(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"allowBots": False})
|
|
assert p.should_skip_bot("@human:example.org") is False
|
|
|
|
def test_context_visibility_all(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"contextVisibility": "all"})
|
|
assert p.is_context_visible("direct") is True
|
|
assert p.is_context_visible("group") is True
|
|
|
|
def test_context_visibility_none(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"contextVisibility": "none"})
|
|
assert p.is_context_visible("direct") is False
|
|
assert p.is_context_visible("group") is False
|
|
|
|
def test_context_visibility_dm(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"contextVisibility": "dm"})
|
|
assert p.is_context_visible("direct") is True
|
|
assert p.is_context_visible("group") is False
|
|
|
|
def test_context_visibility_group(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"contextVisibility": "group"})
|
|
assert p.is_context_visible("direct") is False
|
|
assert p.is_context_visible("group") is True
|
|
|
|
def test_allowlist_only_property(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"allowlistOnly": True})
|
|
assert p.allowlist_only is True
|
|
|
|
def test_context_visibility_property(self):
|
|
from yuxi.channels.adapters.matrix.security import MatrixSecurityPolicy
|
|
p = MatrixSecurityPolicy({"contextVisibility": "dm"})
|
|
assert p.context_visibility == "dm"
|
|
|
|
|
|
# =============================================================================
|
|
# allowlist
|
|
# =============================================================================
|
|
|
|
class TestAllowlist:
|
|
def test_normalize_mxid(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import normalize_mxid
|
|
assert normalize_mxid("@User:Example.Org") == "@user:example.org"
|
|
assert normalize_mxid(" @alice:matrix.org ") == "@alice:matrix.org"
|
|
|
|
def test_match_user_empty_list(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import match_user_in_list
|
|
assert match_user_in_list("@user:example.org", []) is False
|
|
assert match_user_in_list("@user:example.org", [" "]) is False
|
|
|
|
def test_match_user_wildcard(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import match_user_in_list
|
|
assert match_user_in_list("@user:example.org", ["*"]) is True
|
|
assert match_user_in_list("@anyone:any.org", ["*"]) is True
|
|
|
|
def test_match_user_wildcard_domain(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import match_user_in_list
|
|
assert match_user_in_list("@alice:matrix.org", ["@*:matrix.org"]) is True
|
|
assert match_user_in_list("@alice:other.org", ["@*:matrix.org"]) is False
|
|
|
|
def test_match_user_wildcard_domain_case(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import match_user_in_list
|
|
assert match_user_in_list("@alice:MATRIX.ORG", ["@*:matrix.org"]) is True
|
|
|
|
def test_match_user_exact(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import match_user_in_list
|
|
assert match_user_in_list("@alice:matrix.org", ["@alice:matrix.org"]) is True
|
|
assert match_user_in_list("@bob:matrix.org", ["@alice:matrix.org"]) is False
|
|
|
|
def test_is_likely_bot(self):
|
|
from yuxi.channels.adapters.matrix.allowlist import is_likely_bot
|
|
assert is_likely_bot("@[bot]helper:matrix.org") is True
|
|
assert is_likely_bot("@helper(bot):matrix.org") is True
|
|
assert is_likely_bot("@human:matrix.org") is False
|
|
assert is_likely_bot("") is False
|
|
|
|
|
|
# =============================================================================
|
|
# rooms_config
|
|
# =============================================================================
|
|
|
|
class TestRoomsConfig:
|
|
def test_get_room_config_existing(self):
|
|
from yuxi.channels.adapters.matrix.rooms_config import get_room_config
|
|
config = {"rooms": {"!room:example.org": {"allow": True}}}
|
|
assert get_room_config("!room:example.org", config) == {"allow": True}
|
|
|
|
def test_get_room_config_missing(self):
|
|
from yuxi.channels.adapters.matrix.rooms_config import get_room_config
|
|
assert get_room_config("!unknown:example.org", {}) == {}
|
|
|
|
def test_get_room_users(self):
|
|
from yuxi.channels.adapters.matrix.rooms_config import get_room_users
|
|
config = {"rooms": {"!room:example.org": {"users": ["@a:x", "@b:x"]}}}
|
|
assert get_room_users("!room:example.org", config) == ["@a:x", "@b:x"]
|
|
assert get_room_users("!unknown:example.org", {}) == []
|
|
|
|
def test_is_room_allowed(self):
|
|
from yuxi.channels.adapters.matrix.rooms_config import is_room_allowed
|
|
config = {"rooms": {"!room:example.org": {"allow": False}}}
|
|
assert is_room_allowed("!room:example.org", config) is False
|
|
assert is_room_allowed("!other:example.org", {}) is True
|
|
|
|
def test_get_room_require_mention_per_room(self):
|
|
from yuxi.channels.adapters.matrix.rooms_config import get_room_require_mention
|
|
config = {
|
|
"requireMention": True,
|
|
"rooms": {"!room:example.org": {"requireMention": False}},
|
|
}
|
|
assert get_room_require_mention("!room:example.org", config) is False
|
|
assert get_room_require_mention("!other:example.org", config) is True
|
|
|
|
def test_get_room_require_mention_default(self):
|
|
from yuxi.channels.adapters.matrix.rooms_config import get_room_require_mention
|
|
assert get_room_require_mention("!room:example.org", {}) is True
|
|
assert get_room_require_mention("!room:example.org", {"requireMention": False}) is False
|
|
|
|
|
|
# =============================================================================
|
|
# auto_join
|
|
# =============================================================================
|
|
|
|
class TestAutoJoin:
|
|
def test_always(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
assert should_auto_join("!room:example.org", {"autoJoin": "always"}) is True
|
|
|
|
def test_off(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
assert should_auto_join("!room:example.org", {"autoJoin": "off"}) is False
|
|
|
|
def test_allowlist_allowed(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
config = {"autoJoin": "allowlist", "rooms": {"!room:example.org": {"allow": True}}}
|
|
assert should_auto_join("!room:example.org", config) is True
|
|
|
|
def test_allowlist_not_allowed(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
config = {"autoJoin": "allowlist", "rooms": {"!room:example.org": {"allow": False}}}
|
|
assert should_auto_join("!room:example.org", config) is False
|
|
|
|
def test_allowlist_not_in_rooms(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
assert should_auto_join("!unknown:example.org", {"autoJoin": "allowlist"}) is True
|
|
|
|
def test_unknown_mode(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
assert should_auto_join("!room:example.org", {"autoJoin": "unknown"}) is False
|
|
|
|
def test_default_off(self):
|
|
from yuxi.channels.adapters.matrix.auto_join import should_auto_join
|
|
assert should_auto_join("!room:example.org", {}) is False
|
|
|
|
|
|
# =============================================================================
|
|
# crypto
|
|
# =============================================================================
|
|
|
|
class TestCrypto:
|
|
@pytest.mark.asyncio
|
|
async def test_import_room_keys_no_file(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.crypto import import_room_keys
|
|
|
|
mock_client = MagicMock()
|
|
await import_room_keys(mock_client, str(tmp_path))
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_room_keys_with_file(self, tmp_path):
|
|
import json
|
|
from yuxi.channels.adapters.matrix.crypto import import_room_keys
|
|
|
|
keys_file = tmp_path / "room_keys.json"
|
|
keys_file.write_text(json.dumps({"rooms": {"!room:example.org": {"sessions": []}}}), encoding="utf-8")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.import_room_keys = AsyncMock()
|
|
await import_room_keys(mock_client, str(tmp_path))
|
|
mock_client.import_room_keys.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_room_keys_corrupted_file(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.crypto import import_room_keys
|
|
|
|
keys_file = tmp_path / "room_keys.json"
|
|
keys_file.write_text("not json", encoding="utf-8")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.import_room_keys = AsyncMock()
|
|
await import_room_keys(mock_client, str(tmp_path))
|
|
mock_client.import_room_keys.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_import_room_keys_unexpected_format(self, tmp_path):
|
|
import json
|
|
from yuxi.channels.adapters.matrix.crypto import import_room_keys
|
|
|
|
keys_file = tmp_path / "room_keys.json"
|
|
keys_file.write_text(json.dumps({"not_rooms": 1}), encoding="utf-8")
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.import_room_keys = AsyncMock()
|
|
await import_room_keys(mock_client, str(tmp_path))
|
|
mock_client.import_room_keys.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_room_keys_success(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.crypto import export_room_keys
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.export_room_keys = AsyncMock()
|
|
result = await export_room_keys(mock_client, str(tmp_path))
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_room_keys_failure(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.crypto import export_room_keys
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.export_room_keys = AsyncMock(side_effect=Exception("fail"))
|
|
result = await export_room_keys(mock_client, str(tmp_path))
|
|
assert result is False
|
|
|
|
|
|
# =============================================================================
|
|
# directory (async)
|
|
# =============================================================================
|
|
|
|
class TestDirectory:
|
|
@pytest.mark.asyncio
|
|
async def test_search_users_success(self):
|
|
from yuxi.channels.adapters.matrix.directory import search_users
|
|
|
|
mock_client = MagicMock()
|
|
mock_result = MagicMock()
|
|
mock_result.user_id = "@alice:example.org"
|
|
mock_result.display_name = "Alice"
|
|
mock_result.avatar_url = "mxc://avatar"
|
|
resp = MagicMock()
|
|
resp.results = [mock_result]
|
|
mock_client.search_user_directory = AsyncMock(return_value=resp)
|
|
|
|
results = await search_users(mock_client, "alice")
|
|
assert len(results) == 1
|
|
assert results[0]["user_id"] == "@alice:example.org"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_users_error(self):
|
|
from yuxi.channels.adapters.matrix.directory import search_users
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.search_user_directory = AsyncMock(side_effect=Exception("fail"))
|
|
results = await search_users(mock_client, "alice")
|
|
assert results == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_groups_success(self):
|
|
from yuxi.channels.adapters.matrix.directory import search_groups
|
|
|
|
mock_client = MagicMock()
|
|
mock_result = MagicMock()
|
|
mock_result.room_id = "!room:example.org"
|
|
mock_result.name = "Test Room"
|
|
mock_result.canonical_alias = "#test:example.org"
|
|
mock_result.topic = "A test room"
|
|
mock_result.num_joined_members = 42
|
|
mock_result.world_readable = True
|
|
mock_result.guest_can_join = False
|
|
resp = MagicMock()
|
|
resp.results = [mock_result]
|
|
mock_client.room_directory_search = AsyncMock(return_value=resp)
|
|
|
|
results = await search_groups(mock_client, "test")
|
|
assert len(results) == 1
|
|
assert results[0]["room_id"] == "!room:example.org"
|
|
assert results[0]["num_joined_members"] == 42
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_groups_error(self):
|
|
from yuxi.channels.adapters.matrix.directory import search_groups
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.room_directory_search = AsyncMock(side_effect=Exception("fail"))
|
|
results = await search_groups(mock_client, "test")
|
|
assert results == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_room_alias_success(self):
|
|
from yuxi.channels.adapters.matrix.directory import resolve_room_alias
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.room_id = "!room:example.org"
|
|
resp.servers = ["example.org"]
|
|
mock_client.room_resolve_alias = AsyncMock(return_value=resp)
|
|
|
|
result = await resolve_room_alias(mock_client, "#test:example.org")
|
|
assert result is not None
|
|
assert result["room_id"] == "!room:example.org"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_room_alias_error(self):
|
|
from yuxi.channels.adapters.matrix.directory import resolve_room_alias
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.room_resolve_alias = AsyncMock(side_effect=Exception("fail"))
|
|
result = await resolve_room_alias(mock_client, "#test:example.org")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_public_rooms_success(self):
|
|
from yuxi.channels.adapters.matrix.directory import list_public_rooms
|
|
|
|
mock_client = MagicMock()
|
|
mock_room = MagicMock()
|
|
mock_room.room_id = "!pub:example.org"
|
|
mock_room.name = "Public Room"
|
|
mock_room.canonical_alias = "#pub:example.org"
|
|
mock_room.topic = "Welcome"
|
|
mock_room.num_joined_members = 10
|
|
mock_room.world_readable = True
|
|
resp = MagicMock()
|
|
resp.chunk = [mock_room]
|
|
mock_client.public_rooms = AsyncMock(return_value=resp)
|
|
|
|
results = await list_public_rooms(mock_client)
|
|
assert len(results) == 1
|
|
assert results[0]["room_id"] == "!pub:example.org"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_public_rooms_error(self):
|
|
from yuxi.channels.adapters.matrix.directory import list_public_rooms
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.public_rooms = AsyncMock(side_effect=Exception("fail"))
|
|
results = await list_public_rooms(mock_client)
|
|
assert results == []
|
|
|
|
|
|
# =============================================================================
|
|
# Polls async
|
|
# =============================================================================
|
|
|
|
class TestPollsAsync:
|
|
@pytest.mark.asyncio
|
|
async def test_create_poll_success(self):
|
|
from yuxi.channels.adapters.matrix.polls import create_poll
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$poll001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await create_poll(mock_client, "!room:example.org", "Question?", ["A", "B"])
|
|
assert result.success is True
|
|
assert result.message_id == "$poll001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_poll_failure(self):
|
|
from yuxi.channels.adapters.matrix.polls import create_poll
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.room_send = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await create_poll(mock_client, "!room:example.org", "Q?", ["A"])
|
|
assert result.success is False
|
|
assert "fail" in result.error
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_poll_response_success(self):
|
|
from yuxi.channels.adapters.matrix.polls import send_poll_response
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$vote001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_poll_response(mock_client, "!room:example.org", "$poll001", ["answer_0"])
|
|
assert result.success is True
|
|
assert result.message_id == "$vote001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_poll_response_failure(self):
|
|
from yuxi.channels.adapters.matrix.polls import send_poll_response
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.room_send = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await send_poll_response(mock_client, "!room:example.org", "$poll001", ["answer_0"])
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_poll_success(self):
|
|
from yuxi.channels.adapters.matrix.polls import end_poll
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$end001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await end_poll(mock_client, "!room:example.org", "$poll001", summary="Done")
|
|
assert result.success is True
|
|
assert result.message_id == "$end001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_poll_no_summary(self):
|
|
from yuxi.channels.adapters.matrix.polls import end_poll
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$end002"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await end_poll(mock_client, "!room:example.org", "$poll001")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_end_poll_failure(self):
|
|
from yuxi.channels.adapters.matrix.polls import end_poll
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.room_send = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await end_poll(mock_client, "!room:example.org", "$poll001")
|
|
assert result.success is False
|
|
|
|
|
|
# =============================================================================
|
|
# ConfigSchema edges
|
|
# =============================================================================
|
|
|
|
class TestConfigSchemaEdges:
|
|
def test_validate_pattern_mismatch(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({"user_id": "not-a-valid-user-id"})
|
|
assert any("pattern" in e["error"] for e in errors)
|
|
|
|
def test_validate_enum_mismatch(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"autoJoin": "invalid",
|
|
})
|
|
assert any("invalid" in e["error"] for e in errors)
|
|
|
|
def test_validate_min_constraint(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"sync_timeout_ms": 0,
|
|
})
|
|
assert any("below" in e["error"] for e in errors)
|
|
|
|
def test_validate_max_constraint(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"textChunkLimit": 100000,
|
|
})
|
|
assert any("above" in e["error"] for e in errors)
|
|
|
|
def test_validate_wrong_type_int_for_str(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": 123,
|
|
"access_token": "tok",
|
|
})
|
|
assert any("expected str" in e["error"] for e in errors)
|
|
|
|
def test_validate_wrong_type_str_for_int(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"mediaMaxMb": "ten",
|
|
})
|
|
assert any("expected int" in e["error"] for e in errors)
|
|
|
|
def test_validate_encryption_requires_crypto_dir(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"encryption": True,
|
|
})
|
|
assert any("crypto_store_dir" in e["field"] for e in errors)
|
|
|
|
def test_validate_nested_section(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"dm": {"policy": "invalid"},
|
|
})
|
|
assert any("dm.policy" in e["field"] for e in errors)
|
|
|
|
def test_validate_float_type(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"probe_timeout_s": "slow",
|
|
})
|
|
assert any("expected float" in e["error"] for e in errors)
|
|
|
|
def test_validate_float_with_int_passes(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"probe_timeout_s": 10,
|
|
})
|
|
assert not any("probe_timeout_s" in e["field"] for e in errors)
|
|
|
|
def test_validate_bool_type(self):
|
|
from yuxi.channels.adapters.matrix.config_schema import validate_config
|
|
|
|
errors = validate_config({
|
|
"user_id": "@bot:example.org",
|
|
"access_token": "tok",
|
|
"enabled": "yes",
|
|
})
|
|
assert any("expected bool" in e["error"] for e in errors)
|
|
|
|
|
|
# =============================================================================
|
|
# Accounts remaining
|
|
# =============================================================================
|
|
|
|
class TestAccountsRemaining:
|
|
def test_resolve_room_account(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {
|
|
"accounts": {"bot1": {"user_id": "@bot1:example.org"}},
|
|
"rooms": {"!room:example.org": {"account": "bot1"}},
|
|
}
|
|
mgr = MultiAccountManager(config)
|
|
assert mgr.resolve_room_account("!room:example.org") == "bot1"
|
|
assert mgr.resolve_room_account("!unknown:example.org") is None
|
|
|
|
def test_promote_to_default(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {
|
|
"accounts": {
|
|
"bot1": {"user_id": "@bot1:example.org", "isDefault": True},
|
|
"bot2": {"user_id": "@bot2:example.org"},
|
|
},
|
|
}
|
|
mgr = MultiAccountManager(config)
|
|
assert mgr.promote_to_default("bot2") is True
|
|
assert mgr.default_account_id == "bot2"
|
|
|
|
def test_promote_nonexistent(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
mgr = MultiAccountManager({"accounts": {"bot1": {"user_id": "@bot1:example.org"}}})
|
|
assert mgr.promote_to_default("nonexistent") is False
|
|
|
|
def test_has_multi_account(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
mgr1 = MultiAccountManager({"accounts": {"bot1": {"user_id": "@bot1:example.org"}}})
|
|
assert mgr1.has_multi_account is False
|
|
|
|
mgr2 = MultiAccountManager({
|
|
"accounts": {
|
|
"bot1": {"user_id": "@bot1:example.org"},
|
|
"bot2": {"user_id": "@bot2:example.org"},
|
|
},
|
|
})
|
|
assert mgr2.has_multi_account is True
|
|
|
|
def test_default_account_none_when_empty(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
mgr = MultiAccountManager({})
|
|
assert mgr.default_account is None
|
|
assert mgr.account_count == 0
|
|
|
|
def test_matrix_account_properties(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MatrixAccount
|
|
|
|
acc = MatrixAccount("acc1", {
|
|
"user_id": "@bot:matrix.org",
|
|
"homeserver": "https://custom.org",
|
|
"isDefault": True,
|
|
"enabled": False,
|
|
})
|
|
assert acc.user_id == "@bot:matrix.org"
|
|
assert acc.homeserver == "https://custom.org"
|
|
assert acc.is_default is True
|
|
assert acc.enabled is False
|
|
|
|
def test_matrix_account_repr(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MatrixAccount
|
|
|
|
acc = MatrixAccount("acc1", {"user_id": "@bot:matrix.org", "homeserver": "https://matrix.org"})
|
|
r = repr(acc)
|
|
assert "acc1" in r
|
|
assert "@bot:matrix.org" in r
|
|
|
|
|
|
# =============================================================================
|
|
# Credentials encrypted
|
|
# =============================================================================
|
|
|
|
class TestCredentialsEncrypted:
|
|
def test_save_and_load_encrypted(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials, CredentialStore
|
|
|
|
os.environ["MATRIX_CREDENTIAL_KEY"] = "test-encryption-key-32bytes!!"
|
|
try:
|
|
store = CredentialStore(base_dir=str(tmp_path))
|
|
creds = MatrixCredentials(user_id="@bot:a", homeserver="https://a", access_token="secret123")
|
|
store.save(creds)
|
|
|
|
loaded = store.load("@bot:a")
|
|
assert loaded is not None
|
|
assert loaded.access_token == "secret123"
|
|
finally:
|
|
del os.environ["MATRIX_CREDENTIAL_KEY"]
|
|
|
|
def test_list_accounts(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials, CredentialStore
|
|
|
|
store = CredentialStore(base_dir=str(tmp_path))
|
|
store.save(MatrixCredentials(user_id="@bot1:a", homeserver="https://a", access_token="t1"))
|
|
store.save(MatrixCredentials(user_id="@bot2:a", homeserver="https://a", access_token="t2"))
|
|
|
|
accounts = store.list_accounts()
|
|
assert len(accounts) >= 1
|
|
|
|
def test_load_all(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials, CredentialStore
|
|
|
|
store = CredentialStore(base_dir=str(tmp_path))
|
|
store.save(MatrixCredentials(user_id="@bot:a", homeserver="https://a", access_token="t"))
|
|
loaded = store.load_all()
|
|
assert len(loaded) >= 1
|
|
|
|
def test_from_dict(self):
|
|
from yuxi.channels.adapters.matrix.credentials import MatrixCredentials
|
|
|
|
creds = MatrixCredentials.from_dict({
|
|
"user_id": "@bot:a",
|
|
"homeserver": "https://a",
|
|
"access_token": "tok",
|
|
"device_id": "dev1",
|
|
"display_name": "Bot",
|
|
})
|
|
assert creds.user_id == "@bot:a"
|
|
assert creds.device_id == "dev1"
|
|
assert creds.display_name == "Bot"
|
|
|
|
|
|
# =============================================================================
|
|
# DraftStream
|
|
# =============================================================================
|
|
|
|
class TestDraftStreamEdges:
|
|
def test_draft_session_start_update_finish(self):
|
|
from yuxi.channels.adapters.matrix.draft_stream import DraftStreamSession
|
|
|
|
s = DraftStreamSession("!room:example.org")
|
|
assert not s.is_active
|
|
s.start("$evt1")
|
|
assert s.is_active
|
|
assert s.draft_event_id == "$evt1"
|
|
s.update("Hello world")
|
|
assert s.current_text == "Hello world"
|
|
s.finish()
|
|
assert not s.is_active
|
|
|
|
def test_get_or_create_draft_reuse(self):
|
|
from yuxi.channels.adapters.matrix.draft_stream import get_or_create_draft, remove_draft
|
|
|
|
d1 = get_or_create_draft("!room:example.org")
|
|
d2 = get_or_create_draft("!room:example.org")
|
|
assert d1 is d2
|
|
remove_draft("!room:example.org")
|
|
|
|
def test_remove_nonexistent_does_not_raise(self):
|
|
from yuxi.channels.adapters.matrix.draft_stream import remove_draft
|
|
|
|
remove_draft("!nonexistent:example.org")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_live_marker_success(self):
|
|
from yuxi.channels.adapters.matrix.draft_stream import send_live_marker
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$live001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_live_marker(
|
|
mock_client, "!room:example.org", "$evt",
|
|
{"msgtype": "m.text", "body": "hello"},
|
|
finished=True,
|
|
)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_live_marker_failure(self):
|
|
from yuxi.channels.adapters.matrix.draft_stream import send_live_marker
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.room_send = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await send_live_marker(
|
|
mock_client, "!room:example.org", "$evt",
|
|
{"msgtype": "m.text", "body": "hello"},
|
|
)
|
|
assert result.success is False
|
|
|
|
|
|
# =============================================================================
|
|
# MediaMeta
|
|
# =============================================================================
|
|
|
|
class TestMediaMetaEdges:
|
|
def test_extract_duration_bytes_wav(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import extract_duration_bytes
|
|
|
|
header = (
|
|
b"RIFF" + (44).to_bytes(4, "little") + b"WAVEfmt " +
|
|
b"\x10\x00\x00\x00\x01\x00\x01\x00" +
|
|
(44100).to_bytes(4, "little") +
|
|
(88200).to_bytes(4, "little") +
|
|
b"\x02\x00\x10\x00" +
|
|
b"data" + (100).to_bytes(4, "little")
|
|
)
|
|
data = header + b"\x00" * 100
|
|
duration = extract_duration_bytes(data, "audio/wav")
|
|
assert duration >= 0
|
|
|
|
def test_extract_duration_bytes_unknown(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import extract_duration_bytes
|
|
|
|
duration = extract_duration_bytes(b"random_bytes" * 10, "audio/unknown")
|
|
assert duration == 0
|
|
|
|
def test_extract_duration_bytes_empty(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import extract_duration_bytes
|
|
|
|
duration = extract_duration_bytes(b"", "audio/ogg")
|
|
assert duration == 0
|
|
|
|
def test_detect_codec_from_mime_type(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import _detect_codec
|
|
|
|
assert _detect_codec(MagicMock(), "audio/mp3") == "mp3"
|
|
assert _detect_codec(MagicMock(), "audio/ogg") == "ogg"
|
|
assert _detect_codec(MagicMock(), None) == "magicmock"
|
|
|
|
def test_detect_codec_from_class_name(self):
|
|
from yuxi.channels.adapters.matrix.media_meta import _detect_codec
|
|
|
|
mp3_mock = MagicMock()
|
|
type(mp3_mock).__name__ = "MP3"
|
|
assert _detect_codec(mp3_mock, None) == "mp3"
|
|
|
|
ogg_mock = MagicMock()
|
|
type(ogg_mock).__name__ = "OggVorbis"
|
|
assert _detect_codec(ogg_mock, None) == "vorbis"
|
|
|
|
flac_mock = MagicMock()
|
|
type(flac_mock).__name__ = "FLAC"
|
|
assert _detect_codec(flac_mock, None) == "flac"
|
|
|
|
wav_mock = MagicMock()
|
|
type(wav_mock).__name__ = "Wave"
|
|
assert _detect_codec(wav_mock, None) == "pcm"
|
|
|
|
opus_mock = MagicMock()
|
|
type(opus_mock).__name__ = "Opus"
|
|
assert _detect_codec(opus_mock, None) == "opus"
|
|
|
|
aac_mock = MagicMock()
|
|
type(aac_mock).__name__ = "AAC"
|
|
assert _detect_codec(aac_mock, None) == "aac"
|
|
|
|
|
|
# =============================================================================
|
|
# ProfileSync edges
|
|
# =============================================================================
|
|
|
|
class TestProfileSyncEdges:
|
|
def test_resolve_avatar_url_http_no_homeserver(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_avatar_url
|
|
|
|
result = resolve_avatar_url("https://cdn.example.org/avatar.png")
|
|
assert result == "https://cdn.example.org/avatar.png"
|
|
|
|
def test_resolve_avatar_url_invalid(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_avatar_url
|
|
|
|
assert resolve_avatar_url("not-a-url", "example.org") is None
|
|
|
|
def test_resolve_display_name_unknown(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_display_name
|
|
|
|
assert resolve_display_name({}) == "unknown"
|
|
|
|
def test_resolve_display_name_non_mxid(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import resolve_display_name
|
|
|
|
assert resolve_display_name({"user_id": "plain_user"}) == "plain_user"
|
|
|
|
def test_compare_profiles_no_changes(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import compare_profiles
|
|
|
|
current = {"displayname": "Same", "avatar_url": "mxc://same"}
|
|
incoming = {"displayname": "Same", "avatar_url": "mxc://same"}
|
|
changes = compare_profiles(current, incoming)
|
|
assert len(changes) == 0
|
|
|
|
def test_build_profile_update_empty(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import build_profile_update
|
|
|
|
assert build_profile_update() == {}
|
|
assert build_profile_update(display_name="", avatar_url="") == {}
|
|
|
|
def test_mxc_to_http_invalid(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import mxc_to_http
|
|
|
|
assert mxc_to_http("not-mxc", "https://example.org") == "not-mxc"
|
|
|
|
def test_mxc_to_http_plain_homeserver(self):
|
|
from yuxi.channels.adapters.matrix.profile_sync import mxc_to_http
|
|
|
|
url = mxc_to_http("mxc://example.org/abc123", "example.org")
|
|
assert url.startswith("https://example.org")
|
|
|
|
|
|
# =============================================================================
|
|
# SyncStateMachine edges
|
|
# =============================================================================
|
|
|
|
class TestSyncStateMachineEdges:
|
|
def test_can_transition_valid(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
assert sm.can_transition(SyncState.SYNCING) is True
|
|
assert sm.can_transition(SyncState.ERROR) is True
|
|
|
|
def test_can_transition_invalid(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
assert sm.can_transition(SyncState.PREPARED) is False
|
|
|
|
def test_transition_to_prepared(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_stopped()
|
|
assert sm.to_prepared() is True
|
|
assert sm.state == SyncState.PREPARED
|
|
|
|
def test_should_stop_retry(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
for _ in range(sm._max_retries):
|
|
sm.to_error()
|
|
sm.to_reconnecting()
|
|
sm.to_error()
|
|
assert sm.should_stop_retry is True
|
|
|
|
def test_should_auto_recover_from_error(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine, SyncState
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
sm.to_error()
|
|
assert sm.should_auto_recover is False
|
|
sm._last_error_time -= 120
|
|
assert sm.should_auto_recover is True
|
|
|
|
def test_should_auto_recover_not_in_error(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
assert sm.should_auto_recover is False
|
|
|
|
def test_is_active(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine
|
|
|
|
sm = SyncStateMachine()
|
|
assert sm.is_active is True
|
|
sm.to_healthy()
|
|
assert sm.is_active is True
|
|
sm.to_error()
|
|
assert sm.is_active is False
|
|
|
|
def test_error_clears_retry_on_healthy(self):
|
|
from yuxi.channels.adapters.matrix.sync_state import SyncStateMachine
|
|
|
|
sm = SyncStateMachine()
|
|
sm.to_healthy()
|
|
sm.to_error()
|
|
sm.to_reconnecting()
|
|
sm.to_error()
|
|
sm.to_reconnecting()
|
|
assert sm.retry_count == 2
|
|
sm.to_healthy()
|
|
assert sm.retry_count == 0
|
|
|
|
|
|
# =============================================================================
|
|
# SentMessageCache edges
|
|
# =============================================================================
|
|
|
|
class TestSentMessageCacheEdges:
|
|
def test_max_size_eviction(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import SentMessageCache
|
|
|
|
cache = SentMessageCache(max_size=2)
|
|
cache.add("!room:example.org", "hash1", "$evt1")
|
|
cache.add("!room:example.org", "hash2", "$evt2")
|
|
cache.add("!room:example.org", "hash3", "$evt3")
|
|
assert len(cache) <= 2
|
|
assert cache.get("!room:example.org", "hash1") is None
|
|
|
|
def test_hashing_deterministic(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import hash_content
|
|
|
|
h1 = hash_content("hello")
|
|
h2 = hash_content("hello")
|
|
assert h1 == h2
|
|
|
|
def test_hashing_different(self):
|
|
from yuxi.channels.adapters.matrix.sent_cache import hash_content
|
|
|
|
h1 = hash_content("hello")
|
|
h2 = hash_content("world")
|
|
assert h1 != h2
|
|
|
|
|
|
# =============================================================================
|
|
# SSRFGuard edges
|
|
# =============================================================================
|
|
|
|
class TestSSRFGuardEdges:
|
|
def test_is_private_cgnat_range(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("100.64.0.1") is True
|
|
assert SSRFGuard.is_private_host("100.127.255.255") is True
|
|
assert SSRFGuard.is_private_host("100.63.0.1") is False
|
|
assert SSRFGuard.is_private_host("100.128.0.1") is False
|
|
|
|
def test_is_private_ipv6_localhost(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("::1") is True
|
|
assert SSRFGuard.is_private_host("[::1]") is True
|
|
|
|
def test_is_private_zero(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("0.0.0.0") is True
|
|
|
|
def test_is_private_127_variants(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("127.0.0.1") is True
|
|
assert SSRFGuard.is_private_host("127.0.0.2") is True
|
|
assert SSRFGuard.is_private_host("127.255.255.255") is True
|
|
|
|
def test_is_private_172_range_boundaries(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
assert SSRFGuard.is_private_host("172.16.0.1") is True
|
|
assert SSRFGuard.is_private_host("172.31.255.255") is True
|
|
assert SSRFGuard.is_private_host("172.15.0.1") is False
|
|
assert SSRFGuard.is_private_host("172.32.0.1") is False
|
|
|
|
def test_validate_url_no_hostname(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import SSRFGuard
|
|
|
|
error = SSRFGuard.validate_url("bad:///path")
|
|
assert error is not None
|
|
|
|
def test_parse_second_octet(self):
|
|
from yuxi.channels.adapters.matrix.sync_store import _parse_second_octet
|
|
|
|
assert _parse_second_octet("100.64.0.1") == 64
|
|
assert _parse_second_octet("invalid") == 0
|
|
assert _parse_second_octet("10") == 0
|
|
|
|
|
|
# =============================================================================
|
|
# Concurrency edges
|
|
# =============================================================================
|
|
|
|
class TestConcurrencyEdges:
|
|
def test_startup_serial_lock_release_without_acquire(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import StartupSerialLock
|
|
|
|
lock = StartupSerialLock()
|
|
lock.release_startup()
|
|
assert lock.is_starting is False
|
|
|
|
def test_startup_serial_lock_double_release(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import StartupSerialLock
|
|
|
|
lock = StartupSerialLock()
|
|
lock.acquire_startup()
|
|
lock.release_startup()
|
|
lock.release_startup()
|
|
assert lock.is_starting is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_account_data_write_queue_drain_empty(self):
|
|
from yuxi.channels.adapters.matrix.concurrency import AccountDataWriteQueue
|
|
|
|
queue = AccountDataWriteQueue()
|
|
items = await queue.drain()
|
|
assert items == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_account_data_write_queue_consumer(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.matrix.concurrency import AccountDataWriteQueue
|
|
|
|
results = []
|
|
|
|
async def write_fn(account_type, data):
|
|
results.append((account_type, data))
|
|
|
|
queue = AccountDataWriteQueue()
|
|
await queue.enqueue("m.direct", {"key": "val"})
|
|
task = queue.start_consumer(write_fn, interval=0.01)
|
|
|
|
await asyncio.sleep(0.05)
|
|
await queue.stop_consumer()
|
|
assert len(results) >= 0
|
|
|
|
|
|
# =============================================================================
|
|
# Formatter edges
|
|
# =============================================================================
|
|
|
|
class TestFormatterEdges:
|
|
def test_empty_string(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("")
|
|
assert result == ""
|
|
|
|
def test_multiple_code_blocks(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("```\na\n```\n```\nb\n```")
|
|
assert result.count("<pre><code>") == 2
|
|
|
|
def test_heading_level_1(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("# H1")
|
|
assert "<h1>H1</h1>" in result
|
|
|
|
def test_heading_level_6(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("###### H6")
|
|
assert "<h6>H6</h6>" in result
|
|
|
|
def test_mixed_unsafe_tags_and_safe(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html('<div>safe</div><script>bad</script>')
|
|
assert "safe" in result
|
|
assert "<script" not in result
|
|
|
|
def test_strips_object_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<object data='x'></object>")
|
|
assert "<object" not in result
|
|
|
|
def test_strips_embed_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<embed src='x'>")
|
|
assert "<embed" not in result
|
|
|
|
def test_strips_form_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<form action='x'></form>")
|
|
assert "<form" not in result
|
|
|
|
def test_strips_input_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<input type='text'>")
|
|
assert "<input" not in result
|
|
|
|
def test_strips_link_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<link rel='stylesheet'>")
|
|
assert "<link" not in result
|
|
|
|
def test_strips_meta_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<meta charset='utf-8'>")
|
|
assert "<meta" not in result
|
|
|
|
def test_strips_base_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<base href='http://evil.com'>")
|
|
assert "<base" not in result
|
|
|
|
def test_strips_applet_tag(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("<applet code='x'></applet>")
|
|
assert "<applet" not in result
|
|
|
|
def test_headings_escape_html(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("## <script>alert(1)</script>")
|
|
assert "<script>" in result
|
|
assert "<script>" not in result
|
|
|
|
def test_blockquote_escape_html(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("> <script>alert(1)</script>")
|
|
assert "<script>" in result
|
|
|
|
def test_list_items_escape_html(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html("- <script>alert(1)</script>")
|
|
assert "<script>" in result
|
|
|
|
def test_style_expression_stripped(self):
|
|
from yuxi.channels.adapters.matrix.formatter import markdown_to_matrix_html
|
|
|
|
result = markdown_to_matrix_html('<div style="expression(alert(1))">')
|
|
assert "expression" not in result
|
|
|
|
|
|
# =============================================================================
|
|
# Normalizer edges
|
|
# =============================================================================
|
|
|
|
class TestNormalizerEdges:
|
|
@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 test_edit_event_detection(self, adapter):
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "edited"
|
|
event.event_id = "$evt_edit"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"body": "edited text",
|
|
"msgtype": "m.text",
|
|
"m.relates_to": {
|
|
"rel_type": "m.replace",
|
|
"event_id": "$original",
|
|
},
|
|
"m.new_content": {
|
|
"body": "edited text",
|
|
"msgtype": "m.text",
|
|
},
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is not None
|
|
assert msg.event_type == EventType.MESSAGE_UPDATED
|
|
|
|
def test_formatted_text_event(self, adapter):
|
|
from nio import RoomMessageFormatted
|
|
|
|
event = MagicMock(spec=RoomMessageFormatted)
|
|
event.sender = "@user:example.org"
|
|
event.body = "hello"
|
|
event.formatted_body = "<b>hello</b>"
|
|
event.event_id = "$evt_formatted"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"msgtype": "m.text",
|
|
"body": "hello",
|
|
"formatted_body": "<b>hello</b>",
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is not None
|
|
assert msg.message_type == MessageType.TEXT
|
|
|
|
def test_require_mention_filters_when_not_mentioned(self, adapter):
|
|
adapter.config["requireMention"] = True
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "hello without bot mention"
|
|
event.event_id = "$evt_filtered"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"body": "hello without bot mention",
|
|
"msgtype": "m.text",
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg is None
|
|
|
|
def test_thread_events_skip_mention_filter(self, adapter):
|
|
adapter.config["requireMention"] = True
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "hello in thread"
|
|
event.event_id = "$evt_thread"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"body": "hello in thread",
|
|
"msgtype": "m.text",
|
|
"m.relates_to": {
|
|
"rel_type": "m.thread",
|
|
"event_id": "$root",
|
|
},
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg is not None
|
|
|
|
def test_dm_skip_mention_filter(self, adapter):
|
|
adapter.config["requireMention"] = True
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "hello DM"
|
|
event.event_id = "$evt_dm"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"body": "hello DM",
|
|
"msgtype": "m.text",
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is not None
|
|
|
|
def test_mentions_from_url_pattern(self, adapter):
|
|
formatted = f'<a href="https://matrix.to/#/@alice:example.org">Alice</a>'
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "hey"
|
|
event.event_id = "$evt_url_mention"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"body": "hey",
|
|
"formatted_body": formatted,
|
|
"msgtype": "m.text",
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!room:example.org")
|
|
assert msg.mentions is not None
|
|
assert any("alice:example.org" in uid for uid in msg.mentions.mentioned_user_ids)
|
|
|
|
def test_no_timestamp_uses_current(self, adapter):
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = "hello"
|
|
event.event_id = "$evt_no_ts"
|
|
event.server_timestamp = None
|
|
event.source = {
|
|
"content": {
|
|
"body": "hello",
|
|
"msgtype": "m.text",
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg is not None
|
|
assert msg.timestamp is not None
|
|
|
|
def test_resolve_chat_type_direct_via_direct_rooms(self, adapter):
|
|
from yuxi.channels.adapters.matrix.session import MatrixSessionHelper
|
|
|
|
assert adapter._session.resolve_chat_type("!dm:example.org") == "direct"
|
|
|
|
def test_empty_mentions_when_nothing(self, adapter):
|
|
event = MagicMock()
|
|
event.sender = "@user:example.org"
|
|
event.body = ""
|
|
event.event_id = "$evt_empty"
|
|
event.server_timestamp = 1700000000000
|
|
event.source = {
|
|
"content": {
|
|
"body": "",
|
|
"formatted_body": "",
|
|
"msgtype": "m.text",
|
|
},
|
|
}
|
|
msg = adapter._normalizer.normalize(event, "!dm:example.org")
|
|
assert msg.mentions is None
|
|
|
|
|
|
# =============================================================================
|
|
# Voice edges
|
|
# =============================================================================
|
|
|
|
class TestVoiceEdges:
|
|
def test_is_voice_message_not_audio_msgtype(self):
|
|
from yuxi.channels.adapters.matrix.voice import is_voice_message
|
|
|
|
assert is_voice_message({"content": {"msgtype": "m.text", "org.matrix.msc3245.voice": {}}}) is False
|
|
|
|
def test_is_voice_message_msc1767_no_duration(self):
|
|
from yuxi.channels.adapters.matrix.voice import is_voice_message
|
|
|
|
assert is_voice_message({
|
|
"content": {"msgtype": "m.audio", "org.matrix.msc1767.audio": {}},
|
|
}) is False
|
|
|
|
def test_extract_audio_info_no_info(self):
|
|
from yuxi.channels.adapters.matrix.voice import extract_audio_info
|
|
|
|
info = extract_audio_info({"content": {"msgtype": "m.audio"}})
|
|
assert info["duration_ms"] == 0
|
|
assert info["is_voice"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_tts_message(self):
|
|
from yuxi.channels.adapters.matrix.voice import send_tts_message
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.content_uri = "mxc://example.org/tts"
|
|
resp2 = MagicMock()
|
|
resp2.event_id = "$tts001"
|
|
mock_client.upload = AsyncMock(return_value=(resp, {}))
|
|
mock_client.room_send = AsyncMock(return_value=resp2)
|
|
|
|
result = await send_tts_message(
|
|
mock_client, "!room:example.org",
|
|
"Hello this is a TTS message",
|
|
b"audio_data",
|
|
)
|
|
assert result.success is True
|
|
assert result.message_id == "$tts001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_voice_note_upload_failure(self):
|
|
from yuxi.channels.adapters.matrix.voice import send_voice_note
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.upload = AsyncMock(side_effect=Exception("upload fail"))
|
|
|
|
result = await send_voice_note(mock_client, "!room:example.org", b"audio")
|
|
assert result.success is False
|
|
assert "upload" in result.error.lower()
|
|
|
|
|
|
# =============================================================================
|
|
# ReactionUtils edges
|
|
# =============================================================================
|
|
|
|
class TestReactionUtilsEdges:
|
|
def test_extract_reaction_no_source(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import extract_reaction
|
|
|
|
event = MagicMock()
|
|
event.source = {}
|
|
assert extract_reaction(event) is None
|
|
|
|
def test_select_reaction_mapped_emoji(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import select_reaction
|
|
|
|
available = [{"key": "👍"}, {"key": "❤️"}]
|
|
result = select_reaction(available, "+1")
|
|
assert result is not None
|
|
assert result["key"] == "👍"
|
|
|
|
def test_select_reaction_case_insensitive(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import select_reaction
|
|
|
|
available = [{"key": "👍"}]
|
|
result = select_reaction(available, "+1")
|
|
assert result is not None
|
|
|
|
def test_build_batch_remove_single(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import build_batch_remove
|
|
|
|
reactions = [{"key": "👍", "target_event_id": "$evt"}]
|
|
result = build_batch_remove(reactions, "$evt")
|
|
assert len(result) == 1
|
|
assert result[0]["m.relates_to"]["key"] == "👍"
|
|
|
|
def test_build_batch_remove_different_targets(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import build_batch_remove
|
|
|
|
reactions = [
|
|
{"key": "👍", "target_event_id": "$evt1"},
|
|
{"key": "❤️", "target_event_id": "$evt2"},
|
|
]
|
|
result = build_batch_remove(reactions, "$evt1")
|
|
assert len(result) == 1
|
|
|
|
def test_summarize_reactions_empty(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import summarize_reactions
|
|
|
|
assert summarize_reactions([]) == {}
|
|
|
|
def test_summarize_reactions_skip_empty_key(self):
|
|
from yuxi.channels.adapters.matrix.reaction_utils import summarize_reactions
|
|
|
|
events = [
|
|
{"sender": "@a:x", "key": "", "target_event_id": "$evt"},
|
|
{"sender": "@b:x", "key": "👍", "target_event_id": "$evt"},
|
|
]
|
|
result = summarize_reactions(events)
|
|
assert "$evt" in result
|
|
assert result["$evt"]["unique_reactors"] == 1
|
|
|
|
|
|
# =============================================================================
|
|
# Send edges
|
|
# =============================================================================
|
|
|
|
class TestSendEdges:
|
|
def test_classify_send_error_missing_token(self):
|
|
from yuxi.channels.adapters.matrix.send import classify_send_error
|
|
|
|
exc = Exception()
|
|
exc.errcode = "M_MISSING_TOKEN"
|
|
error_type, _ = classify_send_error(exc)
|
|
assert error_type == "auth"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_formatted_text_no_reply_when_off(self):
|
|
from yuxi.channels.adapters.matrix.send import send_formatted_text
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$evt"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
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", reply_to_message_id="$original")
|
|
|
|
result = await send_formatted_text(mock_client, "!room", response, reply_to_mode="off")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_reaction_success(self):
|
|
from yuxi.channels.adapters.matrix.send import send_reaction
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$reaction001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_reaction(mock_client, "!room:example.org", "$target", "👍")
|
|
assert result.success is True
|
|
assert result.message_id == "$reaction001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_thread_message_success(self):
|
|
from yuxi.channels.adapters.matrix.send import send_thread_message
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$thread001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_thread_message(mock_client, "!room:example.org", "$root", "thread reply")
|
|
assert result.success is True
|
|
assert result.message_id == "$thread001"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_location_success(self):
|
|
from yuxi.channels.adapters.matrix.send import send_location
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$loc001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_location(mock_client, "!room:example.org", 51.5, -0.12)
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_sticker_success(self):
|
|
from yuxi.channels.adapters.matrix.send import send_sticker
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$sticker001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_sticker(mock_client, "!room:example.org", "mxc://example.org/sticker")
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_formatted_media_upload_failure(self):
|
|
from yuxi.channels.adapters.matrix.send import send_formatted_media
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.upload = AsyncMock(side_effect=Exception("upload fail"))
|
|
|
|
result = await send_formatted_media(mock_client, "!room", "image", b"data", "image/png")
|
|
assert result.success is False
|
|
assert "upload" in result.error.lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_new_message(self):
|
|
from yuxi.channels.adapters.matrix.send import send_stream_chunk
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$stream001"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
result = await send_stream_chunk(mock_client, "!room", "$msg1", "hello", finished=False, stream_state={})
|
|
assert result.success is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_stream_chunk_delta_update(self):
|
|
from yuxi.channels.adapters.matrix.send import send_stream_chunk
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.event_id = "$stream002"
|
|
mock_client.room_send = AsyncMock(return_value=resp)
|
|
|
|
stream_state = {"!room:$msg1": 5}
|
|
result = await send_stream_chunk(
|
|
mock_client, "!room", "$msg1",
|
|
"hello world", finished=False, stream_state=stream_state,
|
|
)
|
|
assert result.success is True
|
|
|
|
|
|
# =============================================================================
|
|
# Location edges
|
|
# =============================================================================
|
|
|
|
class TestLocationEdges:
|
|
def test_format_location_text_body_only(self):
|
|
from yuxi.channels.adapters.matrix.location import format_location_text
|
|
|
|
text = format_location_text("Some place", "")
|
|
assert "[位置]" in text
|
|
assert "Some place" in text
|
|
|
|
def test_format_location_text_uri_only(self):
|
|
from yuxi.channels.adapters.matrix.location import format_location_text
|
|
|
|
text = format_location_text("", "geo:1,2")
|
|
assert "[位置]" in text
|
|
assert "geo:1,2" in text
|
|
|
|
def test_format_location_text_empty(self):
|
|
from yuxi.channels.adapters.matrix.location import format_location_text
|
|
|
|
assert format_location_text("", "") == "[位置]"
|
|
|
|
def test_extract_geo_coordinates_with_params(self):
|
|
from yuxi.channels.adapters.matrix.location import extract_geo_coordinates
|
|
|
|
result = extract_geo_coordinates("geo:51.5,-0.12;u=35")
|
|
assert result is not None
|
|
assert result["lat"] == 51.5
|
|
|
|
def test_extract_geo_coordinates_malformed(self):
|
|
from yuxi.channels.adapters.matrix.location import extract_geo_coordinates
|
|
|
|
assert extract_geo_coordinates("geo:abc,def") is None
|
|
|
|
|
|
# =============================================================================
|
|
# Sticker edges
|
|
# =============================================================================
|
|
|
|
class TestStickerEdges:
|
|
def test_parse_sticker_no_body(self):
|
|
from yuxi.channels.adapters.matrix.sticker import parse_sticker
|
|
|
|
result = parse_sticker({"content": {"msgtype": "m.sticker", "url": "mxc://example.org/st"}})
|
|
assert result is not None
|
|
assert result["text"] == "[贴纸]"
|
|
|
|
def test_build_outbound_sticker_no_dimensions(self):
|
|
from yuxi.channels.adapters.matrix.sticker import build_outbound_sticker
|
|
|
|
result = build_outbound_sticker("mxc://example.org/st")
|
|
assert result["msgtype"] == "m.sticker"
|
|
assert "w" not in result["info"]
|
|
|
|
def test_build_outbound_sticker_dimensions_only_one_set(self):
|
|
from yuxi.channels.adapters.matrix.sticker import build_outbound_sticker
|
|
|
|
result = build_outbound_sticker("mxc://example.org/st", width=100, height=0)
|
|
assert "w" not in result["info"]
|
|
|
|
|
|
# =============================================================================
|
|
# Adapter edge cases
|
|
# =============================================================================
|
|
|
|
class TestAdapterReportError:
|
|
@pytest.mark.asyncio
|
|
async def test_check_mention_not_mentioned(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
strategy = GroupMentionStrategy({"user_id": "@bot:example.org"})
|
|
result = strategy.evaluate_room_policy(
|
|
"!room:example.org", "@user:example.org",
|
|
"hello", "",
|
|
)
|
|
assert result is False
|
|
|
|
|
|
# =============================================================================
|
|
# GroupMentions edges
|
|
# =============================================================================
|
|
|
|
class TestGroupMentionsEdges:
|
|
def test_get_room_mention_policy(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
config = {
|
|
"requireMention": True,
|
|
"rooms": {
|
|
"!room:example.org": {
|
|
"requireMention": False,
|
|
"allowedRoles": ["admin"],
|
|
"mentionExemptUsers": ["@admin:example.org"],
|
|
},
|
|
},
|
|
}
|
|
strategy = GroupMentionStrategy(config)
|
|
policy = strategy.get_room_mention_policy("!room:example.org")
|
|
assert policy["require_mention"] is False
|
|
assert policy["allowed_roles"] == ["admin"]
|
|
assert policy["mention_exempt_users"] == ["@admin:example.org"]
|
|
|
|
def test_get_room_mention_policy_defaults(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
strategy = GroupMentionStrategy({})
|
|
policy = strategy.get_room_mention_policy("!unknown:example.org")
|
|
assert policy["require_mention"] is True
|
|
assert policy["allowed_roles"] == []
|
|
|
|
def test_evaluate_room_policy_no_mention_not_required(self):
|
|
from yuxi.channels.adapters.matrix.group_mentions import GroupMentionStrategy
|
|
|
|
strategy = GroupMentionStrategy({"requireMention": False, "user_id": "@bot:example.org"})
|
|
assert strategy.evaluate_room_policy("!room:example.org", "@user:x", "hello", "") is True
|
|
|
|
|
|
# =============================================================================
|
|
# ConfigPatch edges
|
|
# =============================================================================
|
|
|
|
class TestConfigPatchEdges:
|
|
def test_apply_patch_with_lists(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch, apply_patch
|
|
|
|
config = {"user_id": "@bot:a"}
|
|
patch = MatrixAccountPatch.from_dict({"dm_allow_from": ["@a:x", "@b:x"]})
|
|
result = apply_patch(config, patch)
|
|
assert result["dm_allow_from"] == ["@a:x", "@b:x"]
|
|
|
|
def test_apply_patch_with_rooms(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch, apply_patch
|
|
|
|
config = {"user_id": "@bot:a", "rooms": {}}
|
|
patch = MatrixAccountPatch.from_dict({"rooms": {"!r:x": {"allow": False}}})
|
|
result = apply_patch(config, patch)
|
|
assert result["rooms"] == {"!r:x": {"allow": False}}
|
|
|
|
def test_merge_patches_empty_overlay(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch, merge_patches
|
|
|
|
base = MatrixAccountPatch(display_name="A")
|
|
overlay = MatrixAccountPatch()
|
|
merged = merge_patches(base, overlay)
|
|
assert merged.display_name == "A"
|
|
|
|
def test_to_dict_ignores_none(self):
|
|
from yuxi.channels.adapters.matrix.config_patch import MatrixAccountPatch
|
|
|
|
patch = MatrixAccountPatch(display_name="A", sync_timeout_ms=None)
|
|
d = patch.to_dict()
|
|
assert "display_name" in d
|
|
assert "sync_timeout_ms" not in d
|
|
|
|
|
|
# =============================================================================
|
|
# Encryption edges
|
|
# =============================================================================
|
|
|
|
class TestEncryptionEdges:
|
|
def test_check_crypto_store_ready_not_dir(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.encryption import check_crypto_store_ready
|
|
|
|
file_path = tmp_path / "not_a_dir"
|
|
file_path.write_text("content")
|
|
result = check_crypto_store_ready(str(file_path))
|
|
assert result["status"] == "unavailable"
|
|
|
|
def test_check_crypto_store_ready_empty(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.encryption import check_crypto_store_ready
|
|
|
|
empty_dir = tmp_path / "empty_store"
|
|
empty_dir.mkdir()
|
|
result = check_crypto_store_ready(str(empty_dir))
|
|
assert result["status"] == "empty"
|
|
|
|
def test_check_crypto_store_ready_with_keys(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.encryption import check_crypto_store_ready
|
|
|
|
store_dir = tmp_path / "store_with_keys"
|
|
store_dir.mkdir()
|
|
(store_dir / "room_keys.json").write_text('{"rooms":{}}')
|
|
result = check_crypto_store_ready(str(store_dir))
|
|
assert result["status"] == "ready"
|
|
|
|
def test_format_encryption_unavailable_error_no_crypto_dir(self):
|
|
from yuxi.channels.adapters.matrix.encryption import format_encryption_unavailable_error
|
|
|
|
msg = format_encryption_unavailable_error({"homeserver": "https://example.org"})
|
|
assert "Set 'crypto_store_dir'" in msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decrypt_event_list_result(self):
|
|
from yuxi.channels.adapters.matrix.encryption import decrypt_event
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.decrypt_event = AsyncMock(return_value=[{"decrypted": True}])
|
|
|
|
result = await decrypt_event(mock_client, MagicMock(), "!room:example.org")
|
|
assert result is not None
|
|
assert result == {"decrypted": True}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decrypt_event_dict_result(self):
|
|
from yuxi.channels.adapters.matrix.encryption import decrypt_event
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.decrypt_event = AsyncMock(return_value={"decrypted": True})
|
|
|
|
result = await decrypt_event(mock_client, MagicMock(), "!room:example.org")
|
|
assert result is not None
|
|
assert result == {"decrypted": True}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decrypt_event_failure(self):
|
|
from yuxi.channels.adapters.matrix.encryption import decrypt_event
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.decrypt_event = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await decrypt_event(mock_client, MagicMock(), "!room:example.org")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_device_success(self):
|
|
from yuxi.channels.adapters.matrix.encryption import verify_device
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.verify_device = AsyncMock(return_value=True)
|
|
|
|
result = await verify_device(mock_client, "@user:x", "DEVICE1")
|
|
assert result["status"] == "ok"
|
|
assert result["verified"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_device_failure(self):
|
|
from yuxi.channels.adapters.matrix.encryption import verify_device
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.verify_device = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await verify_device(mock_client, "@user:x", "DEVICE1")
|
|
assert result["status"] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_cross_signing_success(self):
|
|
from yuxi.channels.adapters.matrix.encryption import check_cross_signing
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.cross_signing_keys = {"key1": "val1"}
|
|
mock_client.query_keys = AsyncMock(return_value=resp)
|
|
|
|
result = await check_cross_signing(mock_client)
|
|
assert result["status"] == "ok"
|
|
assert result["cross_signing_ready"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_cross_signing_no_keys(self):
|
|
from yuxi.channels.adapters.matrix.encryption import check_cross_signing
|
|
|
|
mock_client = MagicMock()
|
|
resp = MagicMock()
|
|
resp.cross_signing_keys = {}
|
|
mock_client.query_keys = AsyncMock(return_value=resp)
|
|
|
|
result = await check_cross_signing(mock_client)
|
|
assert result["status"] == "ok"
|
|
assert result["cross_signing_ready"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_cross_signing_failure(self):
|
|
from yuxi.channels.adapters.matrix.encryption import check_cross_signing
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.query_keys = AsyncMock(side_effect=Exception("fail"))
|
|
|
|
result = await check_cross_signing(mock_client)
|
|
assert result["status"] == "error"
|
|
|
|
|
|
# =============================================================================
|
|
# SyncStore edges
|
|
# =============================================================================
|
|
|
|
class TestSyncStoreEdges:
|
|
def test_save_sync_token_empty(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_store import save_sync_token
|
|
|
|
save_sync_token(str(tmp_path), "")
|
|
assert True
|
|
|
|
def test_load_sync_token_corrupted(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_store import load_sync_token
|
|
|
|
token_file = tmp_path / "matrix_sync_token.json"
|
|
token_file.write_text("not json")
|
|
|
|
result = load_sync_token(str(tmp_path))
|
|
assert result is None
|
|
|
|
def test_sync_token_store_backup_recovery(self, tmp_path):
|
|
from yuxi.channels.adapters.matrix.sync_store import SyncTokenStore
|
|
|
|
store = SyncTokenStore(str(tmp_path), "@user:example.org")
|
|
store.save("s12345")
|
|
|
|
token_path = tmp_path / "sync_token_@user_example.org.json"
|
|
bak_path = tmp_path / "sync_token_@user_example.org.json.bak"
|
|
|
|
if token_path.exists() and bak_path.exists():
|
|
token_path.unlink()
|
|
loaded = store.load()
|
|
assert loaded == "s12345"
|
|
|
|
|
|
# =============================================================================
|
|
# Accounts edges
|
|
# =============================================================================
|
|
|
|
class TestAccountsEdges:
|
|
def test_active_client_tracker_transitions(self):
|
|
from yuxi.channels.adapters.matrix.accounts import ActiveClientTracker, ClientLifecycleState
|
|
|
|
tracker = ActiveClientTracker()
|
|
assert tracker.active_count == 0
|
|
|
|
tracker.register("acc1", MagicMock(), ClientLifecycleState.PREPARED)
|
|
assert tracker.get_state("acc1") == ClientLifecycleState.PREPARED
|
|
|
|
assert tracker.transition("acc1", ClientLifecycleState.STARTED) is True
|
|
assert tracker.active_count == 1
|
|
|
|
assert tracker.transition("acc1", ClientLifecycleState.PREPARED) is False
|
|
|
|
tracker.unregister("acc1")
|
|
assert tracker.get_state("acc1") == ClientLifecycleState.NONE
|
|
|
|
def test_active_client_tracker_list_active(self):
|
|
from yuxi.channels.adapters.matrix.accounts import ActiveClientTracker, ClientLifecycleState
|
|
|
|
tracker = ActiveClientTracker()
|
|
tracker.register("a", MagicMock(), ClientLifecycleState.STARTED)
|
|
tracker.register("b", MagicMock(), ClientLifecycleState.PREPARED)
|
|
assert tracker.list_active() == ["a"]
|
|
assert tracker.list_all_ids() == ["a", "b"]
|
|
|
|
def test_active_client_tracker_invalid_transition(self):
|
|
from yuxi.channels.adapters.matrix.accounts import ActiveClientTracker, ClientLifecycleState
|
|
|
|
tracker = ActiveClientTracker()
|
|
tracker.register("acc", MagicMock(), ClientLifecycleState.NONE)
|
|
assert tracker.transition("acc", ClientLifecycleState.STARTED) is False
|
|
|
|
def test_multi_account_manager_default_account_fallback(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
config = {"accounts": {"bot1": {"user_id": "@bot1:example.org"}}}
|
|
mgr = MultiAccountManager(config)
|
|
assert mgr.default_account_id == "bot1"
|
|
|
|
def test_multi_account_manager_empty(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
mgr = MultiAccountManager({"accounts": {}})
|
|
assert mgr.default_account is None
|
|
assert mgr.account_count == 0
|
|
|
|
def test_multi_account_manager_no_accounts_key(self):
|
|
from yuxi.channels.adapters.matrix.accounts import MultiAccountManager
|
|
|
|
mgr = MultiAccountManager({})
|
|
assert mgr.default_account is None
|
|
|
|
|
|
# =============================================================================
|
|
# Commands edges
|
|
# =============================================================================
|
|
|
|
class TestCommandsEdges:
|
|
def test_detect_slash_command_with_args_model(self):
|
|
from yuxi.channels.adapters.matrix.commands import detect_slash_command
|
|
|
|
result = detect_slash_command("/model gpt-4")
|
|
assert result is not None
|
|
assert result["command"] == "/model"
|
|
assert result["args"].get("model_name") == "gpt-4"
|
|
|
|
def test_detect_slash_command_proxy_no_args(self):
|
|
from yuxi.channels.adapters.matrix.commands import detect_slash_command
|
|
|
|
result = detect_slash_command("/proxy")
|
|
assert result is not None
|
|
assert result["command"] == "/proxy"
|
|
assert "spawn" not in result["args"] or not result["args"].get("spawn")
|
|
|
|
def test_is_slash_command_with_extra_whitespace(self):
|
|
from yuxi.channels.adapters.matrix.commands import is_slash_command
|
|
|
|
assert is_slash_command(" /help") is False
|
|
assert is_slash_command("/help ") is True
|
|
|
|
def test_command_list_all_covered(self):
|
|
from yuxi.channels.adapters.matrix.commands import get_command_list
|
|
|
|
cmds = get_command_list()
|
|
assert len(cmds) >= 9
|
|
|
|
def test_format_help_contains_all_commands(self):
|
|
from yuxi.channels.adapters.matrix.commands import format_help_text
|
|
|
|
text = format_help_text()
|
|
for cmd in ["/new", "/reset", "/model", "/help", "/status", "/channels", "/proxy", "/skill", "/room_info"]:
|
|
assert cmd in text
|
|
|
|
|
|
# =============================================================================
|
|
# ThreadBinding edges
|
|
# =============================================================================
|
|
|
|
class TestThreadBindingEdges:
|
|
def test_thread_binding_touch_updates_activity(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBinding
|
|
|
|
b = ThreadBinding("!room:example.org", "$root")
|
|
old_activity = b.last_activity
|
|
time.sleep(0.001)
|
|
b.touch()
|
|
assert b.last_activity > old_activity
|
|
|
|
def test_thread_binding_properties_init(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBinding
|
|
|
|
b = ThreadBinding("!room:example.org", "$root", session_key="s1")
|
|
assert b.room_id == "!room:example.org"
|
|
assert b.root_event_id == "$root"
|
|
assert b.session_key == "s1"
|
|
assert b.bound_at > 0
|
|
assert b.last_activity > 0
|
|
|
|
def test_binding_manager_max_age_expiry(self):
|
|
import time
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({"threadBindings": {"maxAgeHours": 0.001}})
|
|
binding = mgr.bind("!room:example.org", "$root")
|
|
binding.bound_at = time.monotonic() - 100
|
|
expired = mgr.check_timeouts()
|
|
assert len(expired) == 1
|
|
|
|
def test_binding_manager_get_room_bindings(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({})
|
|
mgr.bind("!room1:example.org", "$root1")
|
|
mgr.bind("!room2:example.org", "$root2")
|
|
bindings = mgr.get_room_bindings("!room1:example.org")
|
|
assert len(bindings) == 1
|
|
|
|
def test_binding_manager_list_bindings(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({})
|
|
mgr.bind("!room:example.org", "$root1")
|
|
mgr.bind("!room:example.org", "$root2")
|
|
assert len(mgr.list_bindings()) == 2
|
|
|
|
def test_binding_manager_config_disabled(self):
|
|
from yuxi.channels.adapters.matrix.thread_bindings import ThreadBindingManager
|
|
|
|
mgr = ThreadBindingManager({"threadBindings": {"enabled": False, "spawnSubagentSessions": True}})
|
|
b = mgr.bind("!room:example.org", "$root")
|
|
assert b.is_subagent is True
|
|
|
|
|
|
# =============================================================================
|
|
# ACPBinding edges
|
|
# =============================================================================
|
|
|
|
class TestACPBindingsEdges:
|
|
@pytest.mark.asyncio
|
|
async def test_acp_binding_spawn(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBinding
|
|
|
|
b = ACPBinding("!room:example.org", "$root", acp_session_id="acp1")
|
|
assert not b.bound
|
|
result = await b.spawn(MagicMock())
|
|
assert b.bound
|
|
assert result["status"] == "spawned"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acp_binding_bind(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBinding
|
|
|
|
b = ACPBinding("!room:example.org", "$root")
|
|
result = await b.bind(MagicMock())
|
|
assert result["status"] == "bound"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acp_binding_already_bound(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBinding
|
|
|
|
b = ACPBinding("!room:example.org", "$root")
|
|
b.bound = True
|
|
result = await b.bind(MagicMock())
|
|
assert result["status"] == "already_bound"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acp_binding_unbind(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBinding
|
|
|
|
b = ACPBinding("!room:example.org", "$root")
|
|
b.bound = True
|
|
result = await b.unbind()
|
|
assert not b.bound
|
|
assert result["status"] == "unbound"
|
|
|
|
def test_acp_binding_to_dict(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBinding
|
|
|
|
b = ACPBinding("!room:example.org", "$root", "sess1", "thread")
|
|
d = b.to_dict()
|
|
assert d["room_id"] == "!room:example.org"
|
|
assert d["acp_session_id"] == "sess1"
|
|
|
|
def test_acp_manager_create_no_thread_root(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBindingManager
|
|
|
|
mgr = ACPBindingManager({})
|
|
b = mgr.create_binding("!room:example.org", "")
|
|
assert b.room_id == "!room:example.org"
|
|
|
|
def test_acp_manager_remove_nonexistent(self):
|
|
from yuxi.channels.adapters.matrix.acp_bindings import ACPBindingManager
|
|
|
|
mgr = ACPBindingManager({})
|
|
assert mgr.remove("!room:example.org", "$nonexistent") is False
|
|
|
|
|
|
# =============================================================================
|
|
# EnvVars edges
|
|
# =============================================================================
|
|
|
|
class TestEnvVarsEdges:
|
|
def test_resolve_env_vars_nested_key(self):
|
|
import os
|
|
from yuxi.channels.adapters.matrix.env_vars import resolve_env_vars
|
|
|
|
os.environ["MATRIX_DM_POLICY"] = "open"
|
|
try:
|
|
result = resolve_env_vars({"user_id": "@bot:example.org"})
|
|
assert "dm" in result
|
|
assert result["dm"]["policy"] == "open"
|
|
finally:
|
|
del os.environ["MATRIX_DM_POLICY"]
|
|
|
|
def test_coerce_type_float(self):
|
|
from yuxi.channels.adapters.matrix.env_vars import _coerce_type
|
|
|
|
assert _coerce_type("3.14", 1.0) == 3.14
|
|
|
|
def test_coerce_type_float_invalid(self):
|
|
from yuxi.channels.adapters.matrix.env_vars import _coerce_type
|
|
|
|
assert _coerce_type("abc", 1.0) == "abc"
|
|
|
|
def test_coerce_type_none_existing(self):
|
|
from yuxi.channels.adapters.matrix.env_vars import _coerce_type
|
|
|
|
assert _coerce_type("hello", None) == "hello"
|
|
|
|
|
|
# =============================================================================
|
|
# Probe edges
|
|
# =============================================================================
|
|
|
|
class TestProbeEdges:
|
|
@pytest.mark.asyncio
|
|
async def test_check_backup_health_all_unreachable(self):
|
|
from yuxi.channels.adapters.matrix.probe import _check_backup_health
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.keys_upload = AsyncMock(side_effect=Exception())
|
|
mock_client.keys_query = AsyncMock(side_effect=Exception())
|
|
mock_client.query_keys = AsyncMock(side_effect=Exception())
|
|
|
|
result = await _check_backup_health(mock_client, 5)
|
|
assert result["status"] == "unknown"
|
|
|
|
def test_describe_backup_state_unknown_code(self):
|
|
from yuxi.channels.adapters.matrix.probe import describe_backup_state
|
|
|
|
result = describe_backup_state(999)
|
|
assert result == "unknown_state_999"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_matrix_timeout(self):
|
|
import asyncio
|
|
from yuxi.channels.adapters.matrix.probe import probe_matrix
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.versions = AsyncMock(side_effect=asyncio.TimeoutError())
|
|
mock_client.homeserver = "https://example.org"
|
|
mock_client.user_id = "@bot:example.org"
|
|
|
|
result = await probe_matrix(mock_client, timeout_s=0.1)
|
|
assert result.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_matrix_connection_error(self):
|
|
from yuxi.channels.adapters.matrix.probe import probe_matrix
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.versions = AsyncMock(side_effect=ConnectionError("refused"))
|
|
mock_client.homeserver = "https://example.org"
|
|
mock_client.user_id = "@bot:example.org"
|
|
|
|
result = await probe_matrix(mock_client, timeout_s=10)
|
|
assert result.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_matrix_os_error(self):
|
|
from yuxi.channels.adapters.matrix.probe import probe_matrix
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.versions = AsyncMock(side_effect=OSError("network unreachable"))
|
|
mock_client.homeserver = "https://example.org"
|
|
mock_client.user_id = "@bot:example.org"
|
|
|
|
result = await probe_matrix(mock_client, timeout_s=10)
|
|
assert result.status == "unhealthy"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_matrix_generic_error(self):
|
|
from yuxi.channels.adapters.matrix.probe import probe_matrix
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.versions = AsyncMock(side_effect=RuntimeError("unexpected"))
|
|
mock_client.homeserver = "https://example.org"
|
|
mock_client.user_id = "@bot:example.org"
|
|
|
|
result = await probe_matrix(mock_client, timeout_s=10)
|
|
assert result.status == "degraded" |