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"
|