feat: Matrix connector core — rooms, messaging, sync

- rooms: create, join, leave, forget, list, resolve alias, get info/members,
  invite/kick/ban/unban, set name/topic. RoomInfo/MemberInfo/PowerLevels dataclasses.
- messaging: send text (with HTML, reply, thread), notice, emote, image, file,
  sticker, edit, redact, react, typing, read receipts, fully-read markers.
- sync: SyncEngine with event-type/room/global handler dispatch,
  incremental sync with backoff retry, SyncFilter, paginated message history.
- CLI: rooms (list/create/join/leave/info/members/invite), send (text/image/file),
  sync (listen with filters).
- 182 tests pass (60 existing + 122 new).
This commit is contained in:
2026-05-23 12:07:10 -04:00
parent 7423d3bd17
commit 3ddf8170f8
9 changed files with 3350 additions and 4 deletions
+97
View File
@@ -203,3 +203,100 @@ class TestCmdLogout:
args = build_parser().parse_args(["logout"])
result = cmd_logout(args)
assert result == 0
# ---------------------------------------------------------------------------
# New async command parser tests
# ---------------------------------------------------------------------------
class TestNewCommandsParser:
"""Tests for parser entries of rooms, send, sync commands."""
def test_rooms_subparser_exists(self):
parser = build_parser()
choices = parser._subparsers._group_actions[0].choices
assert "rooms" in choices
assert "send" in choices
assert "sync" in choices
def test_rooms_list(self):
args = build_parser().parse_args(["rooms", "list"])
assert args.command == "rooms"
assert args.rooms_action == "list"
def test_rooms_create(self):
args = build_parser().parse_args(
["rooms", "create", "--name", "Test", "--topic", "Hello", "--public"]
)
assert args.rooms_action == "create"
assert args.name == "Test"
assert args.topic == "Hello"
assert args.public is True
def test_rooms_join(self):
args = build_parser().parse_args(["rooms", "join", "!room:example.org"])
assert args.rooms_action == "join"
assert args.room == "!room:example.org"
def test_rooms_leave(self):
args = build_parser().parse_args(["rooms", "leave", "!room:example.org"])
assert args.rooms_action == "leave"
assert args.room == "!room:example.org"
def test_rooms_info(self):
args = build_parser().parse_args(["rooms", "info", "!room:example.org"])
assert args.rooms_action == "info"
assert args.room == "!room:example.org"
def test_rooms_members(self):
args = build_parser().parse_args(["rooms", "members", "!room:example.org"])
assert args.rooms_action == "members"
def test_rooms_invite(self):
args = build_parser().parse_args(
["rooms", "invite", "!room:example.org", "@alice:example.org"]
)
assert args.rooms_action == "invite"
assert args.user == "@alice:example.org"
def test_send_text(self):
args = build_parser().parse_args(["send", "text", "!room:example.org", "Hello world"])
assert args.send_action == "text"
assert args.room == "!room:example.org"
assert args.body == "Hello world"
def test_send_text_with_reply(self):
args = build_parser().parse_args(
["send", "text", "!room:example.org", "Replying", "--reply-to", "$evt:example.org"]
)
assert args.reply_to == "$evt:example.org"
def test_send_image(self):
args = build_parser().parse_args(
["send", "image", "!room:example.org", "/tmp/photo.jpg", "--caption", "My photo"]
)
assert args.send_action == "image"
assert args.path == "/tmp/photo.jpg"
assert args.caption == "My photo"
def test_send_file(self):
args = build_parser().parse_args(
["send", "file", "!room:example.org", "/tmp/doc.pdf"]
)
assert args.send_action == "file"
def test_sync_listen(self):
args = build_parser().parse_args(["sync", "listen"])
assert args.sync_action == "listen"
assert args.filter_type == []
assert args.room == []
assert args.show_all is False
def test_sync_listen_with_filters(self):
args = build_parser().parse_args(
["sync", "listen", "--filter-type", "m.room.message", "--room", "!test:example.org", "--show-all"]
)
assert args.filter_type == ["m.room.message"]
assert args.room == ["!test:example.org"]
assert args.show_all is True
+360
View File
@@ -0,0 +1,360 @@
"""Test Matrix messaging: send text, media, edits, reactions, typing, receipts."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from matrix_hermes.messaging import (
SendResult,
edit_message,
redact_message,
send_emote,
send_file,
send_image,
send_notice,
send_reaction,
send_read_receipt,
send_sticker,
send_text,
send_typing,
set_fully_read,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_client():
"""Return a mock mautrix Client with all messaging methods as AsyncMock."""
client = MagicMock()
client.send_text = AsyncMock(return_value="$event123:example.org")
client.send_message = AsyncMock(return_value="$event456:example.org")
client.send_message_event = AsyncMock(return_value="$edit789:example.org")
client.send_emote = AsyncMock(return_value="$emote000:example.org")
client.send_image = AsyncMock(return_value="$img111:example.org")
client.send_file = AsyncMock(return_value="$file222:example.org")
client.send_sticker = AsyncMock(return_value="$sticker333:example.org")
client.redact = AsyncMock()
client.react = AsyncMock()
client.set_typing = AsyncMock()
client.send_receipt = AsyncMock()
client.set_fully_read_marker = AsyncMock()
return client
# ---------------------------------------------------------------------------
# Data class tests
# ---------------------------------------------------------------------------
class TestSendResult:
def test_success(self) -> None:
r = SendResult(success=True, event_id="$abc:example.org")
assert r.success is True
assert r.event_id == "$abc:example.org"
assert r.error == ""
def test_failure(self) -> None:
r = SendResult(success=False, error="Rate limited")
assert r.success is False
assert r.error == "Rate limited"
assert r.event_id == ""
def test_default_values(self) -> None:
r = SendResult(success=True)
assert r.event_id == ""
assert r.error == ""
# ---------------------------------------------------------------------------
# Core messaging
# ---------------------------------------------------------------------------
class TestSendText:
@pytest.mark.asyncio
async def test_send_simple(self, mock_client) -> None:
result = await send_text(mock_client, "!room:example.org", "Hello world")
assert result.success is True
assert result.event_id == "$event123:example.org"
mock_client.send_text.assert_called_once_with(
"!room:example.org",
text="Hello world",
html=None,
)
@pytest.mark.asyncio
async def test_send_with_html(self, mock_client) -> None:
result = await send_text(
mock_client, "!room:example.org", "Hello", html="<b>Hello</b>"
)
assert result.success is True
mock_client.send_text.assert_called_once_with(
"!room:example.org",
text="Hello",
html="<b>Hello</b>",
)
@pytest.mark.asyncio
async def test_send_with_reply(self, mock_client) -> None:
result = await send_text(
mock_client, "!room:example.org", "Replying!", reply_to="$orig:example.org"
)
assert result.success is True
call_kwargs = mock_client.send_text.call_args[1]
assert "relates_to" in call_kwargs
assert call_kwargs["relates_to"]["m.in_reply_to"]["event_id"] == "$orig:example.org"
@pytest.mark.asyncio
async def test_send_in_thread(self, mock_client) -> None:
result = await send_text(
mock_client, "!room:example.org", "Thread reply", thread_root="$thread:example.org"
)
assert result.success is True
call_kwargs = mock_client.send_text.call_args[1]
assert "relates_to" in call_kwargs
assert call_kwargs["relates_to"]["rel_type"] == "m.thread"
assert call_kwargs["relates_to"]["event_id"] == "$thread:example.org"
@pytest.mark.asyncio
async def test_send_failure(self, mock_client) -> None:
mock_client.send_text.side_effect = Exception("Network error")
result = await send_text(mock_client, "!room:example.org", "fail")
assert result.success is False
assert "Network error" in result.error
class TestSendNotice:
@pytest.mark.asyncio
async def test_send_notice(self, mock_client) -> None:
result = await send_notice(mock_client, "!room:example.org", "Notice text")
assert result.success is True
assert result.event_id == "$event456:example.org"
@pytest.mark.asyncio
async def test_send_notice_with_html(self, mock_client) -> None:
result = await send_notice(
mock_client, "!room:example.org", "Notice", html="<i>Notice</i>"
)
assert result.success is True
@pytest.mark.asyncio
async def test_send_notice_failure(self, mock_client) -> None:
mock_client.send_message.side_effect = Exception("Fail")
result = await send_notice(mock_client, "!room:example.org", "fail")
assert result.success is False
class TestSendEmote:
@pytest.mark.asyncio
async def test_send_emote(self, mock_client) -> None:
result = await send_emote(mock_client, "!room:example.org", "waves")
assert result.success is True
assert result.event_id == "$emote000:example.org"
mock_client.send_emote.assert_called_once_with("!room:example.org", "waves")
@pytest.mark.asyncio
async def test_send_emote_failure(self, mock_client) -> None:
mock_client.send_emote.side_effect = Exception("Fail")
result = await send_emote(mock_client, "!room:example.org", "fail")
assert result.success is False
# ---------------------------------------------------------------------------
# Media
# ---------------------------------------------------------------------------
class TestSendImage:
@pytest.mark.asyncio
async def test_send_image(self, mock_client) -> None:
result = await send_image(mock_client, "!room:example.org", "/tmp/photo.jpg")
assert result.success is True
assert result.event_id == "$img111:example.org"
mock_client.send_image.assert_called_once_with(
"!room:example.org",
"/tmp/photo.jpg",
caption=None,
thread_root=None,
)
@pytest.mark.asyncio
async def test_send_image_with_caption(self, mock_client) -> None:
result = await send_image(
mock_client, "!room:example.org", "/tmp/photo.jpg", caption="My photo"
)
assert result.success is True
@pytest.mark.asyncio
async def test_send_image_failure(self, mock_client) -> None:
mock_client.send_image.side_effect = Exception("Upload failed")
result = await send_image(mock_client, "!room:example.org", "/tmp/bad.jpg")
assert result.success is False
class TestSendFile:
@pytest.mark.asyncio
async def test_send_file(self, mock_client) -> None:
result = await send_file(
mock_client, "!room:example.org", "/tmp/doc.pdf", filename="report.pdf"
)
assert result.success is True
assert result.event_id == "$file222:example.org"
@pytest.mark.asyncio
async def test_send_file_failure(self, mock_client) -> None:
mock_client.send_file.side_effect = Exception("Upload failed")
result = await send_file(mock_client, "!room:example.org", "/tmp/bad.pdf")
assert result.success is False
class TestSendSticker:
@pytest.mark.asyncio
async def test_send_sticker(self, mock_client) -> None:
result = await send_sticker(mock_client, "!room:example.org", "/tmp/sticker.png")
assert result.success is True
mock_client.send_sticker.assert_called_once_with("!room:example.org", "/tmp/sticker.png")
@pytest.mark.asyncio
async def test_send_sticker_failure(self, mock_client) -> None:
mock_client.send_sticker.side_effect = Exception("Fail")
result = await send_sticker(mock_client, "!room:example.org", "/tmp/sticker.png")
assert result.success is False
# ---------------------------------------------------------------------------
# Editing & redaction
# ---------------------------------------------------------------------------
class TestEditMessage:
@pytest.mark.asyncio
async def test_edit_message(self, mock_client) -> None:
result = await edit_message(
mock_client, "!room:example.org", "$orig:example.org", "Fixed text"
)
assert result.success is True
assert result.event_id == "$edit789:example.org"
@pytest.mark.asyncio
async def test_edit_message_with_html(self, mock_client) -> None:
result = await edit_message(
mock_client,
"!room:example.org",
"$orig:example.org",
"Fixed **bold**",
new_html="Fixed <b>bold</b>",
)
assert result.success is True
@pytest.mark.asyncio
async def test_edit_message_failure(self, mock_client) -> None:
mock_client.send_message_event.side_effect = Exception("Fail")
result = await edit_message(
mock_client, "!room:example.org", "$orig:example.org", "edit"
)
assert result.success is False
class TestRedactMessage:
@pytest.mark.asyncio
async def test_redact_success(self, mock_client) -> None:
ok = await redact_message(mock_client, "!room:example.org", "$msg:example.org")
assert ok is True
@pytest.mark.asyncio
async def test_redact_with_reason(self, mock_client) -> None:
ok = await redact_message(
mock_client, "!room:example.org", "$msg:example.org", reason="Offensive"
)
assert ok is True
@pytest.mark.asyncio
async def test_redact_failure(self, mock_client) -> None:
mock_client.redact.side_effect = Exception("No permission")
ok = await redact_message(mock_client, "!room:example.org", "$msg:example.org")
assert ok is False
# ---------------------------------------------------------------------------
# Reactions
# ---------------------------------------------------------------------------
class TestSendReaction:
@pytest.mark.asyncio
async def test_send_reaction(self, mock_client) -> None:
ok = await send_reaction(
mock_client, "!room:example.org", "$msg:example.org", "👍"
)
assert ok is True
mock_client.react.assert_called_once_with("!room:example.org", "$msg:example.org", "👍")
@pytest.mark.asyncio
async def test_send_reaction_failure(self, mock_client) -> None:
mock_client.react.side_effect = Exception("Fail")
ok = await send_reaction(mock_client, "!room:example.org", "$msg:example.org", "❤️")
assert ok is False
# ---------------------------------------------------------------------------
# Typing & receipts
# ---------------------------------------------------------------------------
class TestSendTyping:
@pytest.mark.asyncio
async def test_typing_on(self, mock_client) -> None:
ok = await send_typing(mock_client, "!room:example.org", typing=True, timeout=5000)
assert ok is True
mock_client.set_typing.assert_called_once_with(
"!room:example.org", typing=True, timeout=5000
)
@pytest.mark.asyncio
async def test_typing_off(self, mock_client) -> None:
ok = await send_typing(mock_client, "!room:example.org", typing=False)
assert ok is True
@pytest.mark.asyncio
async def test_typing_failure(self, mock_client) -> None:
mock_client.set_typing.side_effect = Exception("Fail")
ok = await send_typing(mock_client, "!room:example.org")
assert ok is False
class TestSendReadReceipt:
@pytest.mark.asyncio
async def test_send_receipt(self, mock_client) -> None:
ok = await send_read_receipt(mock_client, "!room:example.org", "$msg:example.org")
assert ok is True
mock_client.send_receipt.assert_called_once_with(
"!room:example.org", "$msg:example.org", "m.read"
)
@pytest.mark.asyncio
async def test_send_receipt_failure(self, mock_client) -> None:
mock_client.send_receipt.side_effect = Exception("Fail")
ok = await send_read_receipt(mock_client, "!room:example.org", "$msg:example.org")
assert ok is False
class TestSetFullyRead:
@pytest.mark.asyncio
async def test_set_fully_read(self, mock_client) -> None:
ok = await set_fully_read(mock_client, "!room:example.org", "$msg:example.org")
assert ok is True
mock_client.set_fully_read_marker.assert_called_once_with(
"!room:example.org", "$msg:example.org"
)
@pytest.mark.asyncio
async def test_set_fully_read_failure(self, mock_client) -> None:
mock_client.set_fully_read_marker.side_effect = Exception("Fail")
ok = await set_fully_read(mock_client, "!room:example.org", "$msg:example.org")
assert ok is False
+458
View File
@@ -0,0 +1,458 @@
"""Test Matrix room management: create, join, leave, info, members, invite, kick, ban."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from matrix_hermes.rooms import (
CreateRoomResult,
MemberInfo,
PowerLevels,
RoomInfo,
ban_user,
create_room,
forget_room,
get_joined_rooms,
get_room_info,
get_room_members,
invite_user,
join_room,
kick_user,
leave_room,
resolve_room_alias,
set_room_name,
set_room_topic,
unban_user,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_client():
"""Return a mock mautrix Client with all room methods as AsyncMock."""
client = MagicMock()
client.join_room = AsyncMock()
client.leave_room = AsyncMock()
client.forget_room = AsyncMock()
client.get_joined_rooms = AsyncMock(return_value=["!room1:example.org", "!room2:example.org"])
client.create_room = AsyncMock(return_value="!newroom:example.org")
client.resolve_room_alias = AsyncMock(
return_value={"room_id": "!room1:example.org", "servers": ["example.org"]}
)
client.get_state_event = AsyncMock()
client.get_members = AsyncMock()
client.invite_user = AsyncMock()
client.kick_user = AsyncMock()
client.ban_user = AsyncMock()
client.unban_user = AsyncMock()
client.send_state_event = AsyncMock()
return client
# ---------------------------------------------------------------------------
# Data class tests
# ---------------------------------------------------------------------------
class TestRoomInfo:
def test_default_values(self) -> None:
info = RoomInfo(room_id="!test:example.org")
assert info.room_id == "!test:example.org"
assert info.name == ""
assert info.topic == ""
assert info.member_count == 0
assert info.is_direct is False
def test_full_info(self) -> None:
info = RoomInfo(
room_id="!test:example.org",
name="Test Room",
topic="Testing",
avatar_url="mxc://example.org/avatar",
canonical_alias="#test:example.org",
member_count=42,
joined_members=40,
invited_members=2,
is_direct=False,
is_public=True,
encryption=True,
)
assert info.name == "Test Room"
assert info.topic == "Testing"
assert info.is_public is True
assert info.encryption is True
assert info.joined_members == 40
class TestCreateRoomResult:
def test_success(self) -> None:
r = CreateRoomResult(success=True, room_id="!abc:example.org")
assert r.success is True
assert r.room_id == "!abc:example.org"
assert r.error == ""
def test_failure(self) -> None:
r = CreateRoomResult(success=False, error="Server error")
assert r.success is False
assert r.error == "Server error"
assert r.room_id == ""
class TestMemberInfo:
def test_basic(self) -> None:
m = MemberInfo(user_id="@alice:example.org", display_name="Alice")
assert m.user_id == "@alice:example.org"
assert m.display_name == "Alice"
assert m.membership == ""
assert m.power_level == 0
def test_full(self) -> None:
m = MemberInfo(
user_id="@bob:example.org",
display_name="Bob",
avatar_url="mxc://example.org/bob",
membership="join",
power_level=50,
)
assert m.power_level == 50
assert m.membership == "join"
class TestPowerLevels:
def test_defaults(self) -> None:
pl = PowerLevels()
assert pl.users_default == 0
assert pl.ban == 50
assert pl.invite == 0
def test_custom(self) -> None:
pl = PowerLevels(
users={"@admin:example.org": 100},
users_default=0,
ban=60,
invite=50,
)
assert pl.users["@admin:example.org"] == 100
assert pl.ban == 60
assert pl.invite == 50
# ---------------------------------------------------------------------------
# Room operations tests
# ---------------------------------------------------------------------------
class TestCreateRoom:
@pytest.mark.asyncio
async def test_create_room_basic(self, mock_client) -> None:
result = await create_room(mock_client, name="My Room")
assert result.success is True
assert result.room_id == "!newroom:example.org"
mock_client.create_room.assert_called_once()
@pytest.mark.asyncio
async def test_create_room_with_all_options(self, mock_client) -> None:
result = await create_room(
mock_client,
name="Public Room",
topic="Hello world",
alias_localpart="myroom",
preset="public_chat",
visibility="public",
invitees=["@alice:example.org"],
is_direct=False,
room_version="10",
)
assert result.success is True
call_kwargs = mock_client.create_room.call_args[1]
assert call_kwargs["name"] == "Public Room"
assert call_kwargs["topic"] == "Hello world"
assert call_kwargs["preset"] == "public_chat"
@pytest.mark.asyncio
async def test_create_room_failure(self, mock_client) -> None:
mock_client.create_room.side_effect = Exception("Server error")
result = await create_room(mock_client, name="Fail")
assert result.success is False
assert "Server error" in result.error
class TestJoinRoom:
@pytest.mark.asyncio
async def test_join_success(self, mock_client) -> None:
ok = await join_room(mock_client, "!room:example.org")
assert ok is True
mock_client.join_room.assert_called_once_with("!room:example.org", servers=None)
@pytest.mark.asyncio
async def test_join_with_servers(self, mock_client) -> None:
ok = await join_room(mock_client, "#room:example.org", servers=["matrix.org"])
assert ok is True
mock_client.join_room.assert_called_once_with("#room:example.org", servers=["matrix.org"])
@pytest.mark.asyncio
async def test_join_failure(self, mock_client) -> None:
mock_client.join_room.side_effect = Exception("Room not found")
ok = await join_room(mock_client, "!bad:example.org")
assert ok is False
class TestLeaveRoom:
@pytest.mark.asyncio
async def test_leave_success(self, mock_client) -> None:
ok = await leave_room(mock_client, "!room:example.org")
assert ok is True
mock_client.leave_room.assert_called_once_with("!room:example.org")
@pytest.mark.asyncio
async def test_leave_failure(self, mock_client) -> None:
mock_client.leave_room.side_effect = Exception("Not joined")
ok = await leave_room(mock_client, "!room:example.org")
assert ok is False
class TestForgetRoom:
@pytest.mark.asyncio
async def test_forget_success(self, mock_client) -> None:
ok = await forget_room(mock_client, "!room:example.org")
assert ok is True
@pytest.mark.asyncio
async def test_forget_failure(self, mock_client) -> None:
mock_client.forget_room.side_effect = Exception("Error")
ok = await forget_room(mock_client, "!room:example.org")
assert ok is False
class TestGetJoinedRooms:
@pytest.mark.asyncio
async def test_returns_rooms(self, mock_client) -> None:
rooms = await get_joined_rooms(mock_client)
assert rooms == ["!room1:example.org", "!room2:example.org"]
@pytest.mark.asyncio
async def test_returns_empty_on_error(self, mock_client) -> None:
mock_client.get_joined_rooms.side_effect = Exception("Network error")
rooms = await get_joined_rooms(mock_client)
assert rooms == []
class TestResolveRoomAlias:
@pytest.mark.asyncio
async def test_resolve_success(self, mock_client) -> None:
room_id, servers = await resolve_room_alias(mock_client, "#test:example.org")
assert room_id == "!room1:example.org"
assert servers == ["example.org"]
@pytest.mark.asyncio
async def test_resolve_failure(self, mock_client) -> None:
mock_client.resolve_room_alias.side_effect = Exception("Not found")
room_id, servers = await resolve_room_alias(mock_client, "#bad:example.org")
assert room_id == ""
assert servers == []
# ---------------------------------------------------------------------------
# Room info & members
# ---------------------------------------------------------------------------
class TestGetRoomInfo:
@pytest.mark.asyncio
async def test_get_room_info_basic(self, mock_client) -> None:
mock_client.get_state_event = AsyncMock(side_effect=lambda room_id, event_type: {
"m.room.name": {"name": "Test Room"},
"m.room.topic": {"topic": "Testing stuff"},
"m.room.avatar": {"url": "mxc://example.org/avatar"},
"m.room.canonical_alias": {"alias": "#test:example.org"},
"m.room.join_rules": {"join_rule": "invite"},
"m.room.encryption": {"algorithm": "m.megolm.v1.aes-sha2"},
}.get(event_type, None))
mock_client.get_members = AsyncMock(return_value=[
{"state_key": "@a:example.org", "content": {"membership": "join", "displayname": "A"}},
{"state_key": "@b:example.org", "content": {"membership": "join", "displayname": "B"}},
{"state_key": "@c:example.org", "content": {"membership": "invite", "displayname": "C"}},
])
info = await get_room_info(mock_client, "!room:example.org")
assert info is not None
assert info.name == "Test Room"
assert info.topic == "Testing stuff"
assert info.avatar_url == "mxc://example.org/avatar"
assert info.canonical_alias == "#test:example.org"
assert info.joined_members == 2
assert info.invited_members == 1
assert info.member_count == 3
assert info.encryption is True
assert info.is_public is False
# joined==2 and not is_public → heuristically direct
assert info.is_direct is True
@pytest.mark.asyncio
async def test_get_room_info_public(self, mock_client) -> None:
mock_client.get_state_event = AsyncMock(side_effect=lambda room_id, event_type: {
"m.room.join_rules": {"join_rule": "public"},
}.get(event_type, None))
mock_client.get_members = AsyncMock(return_value=[
{"state_key": "@a:example.org", "content": {"membership": "join"}},
{"state_key": "@b:example.org", "content": {"membership": "join"}},
])
info = await get_room_info(mock_client, "!room:example.org")
assert info is not None
assert info.is_public is True
# public room + not direct since is_public → not direct
assert info.is_direct is False
@pytest.mark.asyncio
async def test_get_room_info_error(self, mock_client) -> None:
# Individual state event exceptions are caught internally,
# so we get a RoomInfo with defaults. Only a total failure
# (e.g. get_members failing) returns None.
mock_client.get_state_event = AsyncMock(side_effect=Exception("Failed"))
mock_client.get_members = AsyncMock(return_value=[])
info = await get_room_info(mock_client, "!room:example.org")
assert info is not None
assert info.room_id == "!room:example.org"
assert info.name == "" # failed state events → empty defaults
class TestGetRoomMembers:
@pytest.mark.asyncio
async def test_returns_members(self, mock_client) -> None:
mock_client.get_members = AsyncMock(return_value=[
{
"state_key": "@alice:example.org",
"content": {"membership": "join", "displayname": "Alice", "avatar_url": "mxc://a"},
},
{
"state_key": "@bob:example.org",
"content": {"membership": "join", "displayname": "Bob"},
},
])
members = await get_room_members(mock_client, "!room:example.org")
assert len(members) == 2
assert members[0].user_id == "@alice:example.org"
assert members[0].display_name == "Alice"
assert members[0].avatar_url == "mxc://a"
assert members[0].membership == "join"
@pytest.mark.asyncio
async def test_returns_empty_on_error(self, mock_client) -> None:
mock_client.get_members.side_effect = Exception("Error")
members = await get_room_members(mock_client, "!room:example.org")
assert members == []
# ---------------------------------------------------------------------------
# Member management
# ---------------------------------------------------------------------------
class TestInviteUser:
@pytest.mark.asyncio
async def test_invite_success(self, mock_client) -> None:
ok = await invite_user(mock_client, "!room:example.org", "@alice:example.org")
assert ok is True
mock_client.invite_user.assert_called_once_with(
"!room:example.org", "@alice:example.org", reason=None
)
@pytest.mark.asyncio
async def test_invite_with_reason(self, mock_client) -> None:
ok = await invite_user(
mock_client, "!room:example.org", "@bob:example.org", reason="Welcome!"
)
assert ok is True
mock_client.invite_user.assert_called_once_with(
"!room:example.org", "@bob:example.org", reason="Welcome!"
)
@pytest.mark.asyncio
async def test_invite_failure(self, mock_client) -> None:
mock_client.invite_user.side_effect = Exception("User blocked")
ok = await invite_user(mock_client, "!room:example.org", "@bad:example.org")
assert ok is False
class TestKickUser:
@pytest.mark.asyncio
async def test_kick_success(self, mock_client) -> None:
ok = await kick_user(mock_client, "!room:example.org", "@spammer:example.org")
assert ok is True
@pytest.mark.asyncio
async def test_kick_failure(self, mock_client) -> None:
mock_client.kick_user.side_effect = Exception("No permission")
ok = await kick_user(mock_client, "!room:example.org", "@admin:example.org")
assert ok is False
class TestBanUser:
@pytest.mark.asyncio
async def test_ban_success(self, mock_client) -> None:
ok = await ban_user(mock_client, "!room:example.org", "@spammer:example.org", reason="Spam")
assert ok is True
@pytest.mark.asyncio
async def test_ban_failure(self, mock_client) -> None:
mock_client.ban_user.side_effect = Exception("No permission")
ok = await ban_user(mock_client, "!room:example.org", "@admin:example.org")
assert ok is False
class TestUnbanUser:
@pytest.mark.asyncio
async def test_unban_success(self, mock_client) -> None:
ok = await unban_user(mock_client, "!room:example.org", "@former_spammer:example.org")
assert ok is True
@pytest.mark.asyncio
async def test_unban_failure(self, mock_client) -> None:
mock_client.unban_user.side_effect = Exception("Error")
ok = await unban_user(mock_client, "!room:example.org", "@user:example.org")
assert ok is False
# ---------------------------------------------------------------------------
# Room state
# ---------------------------------------------------------------------------
class TestSetRoomName:
@pytest.mark.asyncio
async def test_set_name_success(self, mock_client) -> None:
ok = await set_room_name(mock_client, "!room:example.org", "New Name")
assert ok is True
mock_client.send_state_event.assert_called_once_with(
"!room:example.org", "m.room.name", {"name": "New Name"}
)
@pytest.mark.asyncio
async def test_set_name_failure(self, mock_client) -> None:
mock_client.send_state_event.side_effect = Exception("No permission")
ok = await set_room_name(mock_client, "!room:example.org", "Name")
assert ok is False
class TestSetRoomTopic:
@pytest.mark.asyncio
async def test_set_topic_success(self, mock_client) -> None:
ok = await set_room_topic(mock_client, "!room:example.org", "New Topic")
assert ok is True
mock_client.send_state_event.assert_called_once_with(
"!room:example.org", "m.room.topic", {"topic": "New Topic"}
)
@pytest.mark.asyncio
async def test_set_topic_failure(self, mock_client) -> None:
mock_client.send_state_event.side_effect = Exception("No permission")
ok = await set_room_topic(mock_client, "!room:example.org", "Topic")
assert ok is False
+434
View File
@@ -0,0 +1,434 @@
"""Test Matrix sync engine: filtering, sync, event extraction, dispatch."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from matrix_hermes.sync import (
EventHandler,
PaginatedMessages,
SyncEngine,
SyncEvent,
SyncFilter,
SyncResult,
create_filter,
extract_events,
get_messages,
sync_once,
)
# ---------------------------------------------------------------------------
# Data class tests
# ---------------------------------------------------------------------------
class TestSyncFilter:
def test_default(self) -> None:
f = SyncFilter()
assert f.types == []
assert f.not_types == []
assert f.limit == 20
assert f.include_reactions is True
assert f.include_typing is False
def test_to_json_basic(self) -> None:
f = SyncFilter()
j = f.to_json()
assert "room" in j
assert j["room"]["timeline"]["limit"] == 20
assert j["room"]["state"]["lazy_load_members"] is True
def test_to_json_with_filters(self) -> None:
f = SyncFilter(
types=["m.room.message"],
not_types=["m.typing"],
rooms=["!room1:example.org"],
limit=50,
include_typing=True,
include_receipts=True,
)
j = f.to_json()
tl = j["room"]["timeline"]
assert tl["types"] == ["m.room.message"]
assert tl["not_types"] == ["m.typing"]
def test_to_json_excludes_typing_and_receipts(self) -> None:
f = SyncFilter(include_typing=False, include_receipts=False)
j = f.to_json()
if "account_data" in j:
not_types = j["account_data"].get("not_types", [])
assert "m.typing" in not_types
assert "m.receipt" in not_types
class TestSyncResult:
def test_default(self) -> None:
r = SyncResult()
assert r.next_batch == ""
assert r.rooms == {}
assert r.presence == {}
def test_with_data(self) -> None:
r = SyncResult(
next_batch="s12345",
rooms={"join": {"!room:example.org": {}}},
presence={"events": []},
)
assert r.next_batch == "s12345"
assert "!room:example.org" in r.rooms["join"]
class TestSyncEvent:
def test_basic(self) -> None:
e = SyncEvent(
event_id="$evt:example.org",
room_id="!room:example.org",
sender="@alice:example.org",
event_type="m.room.message",
content={"body": "hi", "msgtype": "m.text"},
timestamp=1234567890,
)
assert e.event_id == "$evt:example.org"
assert e.sender == "@alice:example.org"
assert e.content["body"] == "hi"
assert e.timestamp == 1234567890
assert e.state_key == ""
class TestPaginatedMessages:
def test_default(self) -> None:
pm = PaginatedMessages(messages=[])
assert pm.messages == []
assert pm.start == ""
assert pm.has_more is False
def test_with_data(self) -> None:
msgs = [{"event_id": "$1", "content": {"body": "hi"}}]
pm = PaginatedMessages(messages=msgs, start="t1", end="t2", has_more=True)
assert len(pm.messages) == 1
assert pm.has_more is True
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_client():
"""Return a mock mautrix Client with sync methods as AsyncMock."""
client = MagicMock()
client.sync = AsyncMock()
client.create_filter = AsyncMock(return_value="filter_id_123")
client.get_messages = AsyncMock()
return client
@pytest.fixture
def sample_sync_response():
"""Return a realistic Matrix sync response."""
return {
"next_batch": "s72595_4483_1934",
"rooms": {
"join": {
"!room1:example.org": {
"timeline": {
"events": [
{
"event_id": "$evt1:example.org",
"sender": "@alice:example.org",
"type": "m.room.message",
"origin_server_ts": 1680000000000,
"content": {"body": "Hello!", "msgtype": "m.text"},
},
{
"event_id": "$evt2:example.org",
"sender": "@bob:example.org",
"type": "m.room.message",
"origin_server_ts": 1680000001000,
"content": {"body": "Hi!", "msgtype": "m.text"},
},
]
},
"state": {
"events": [
{
"event_id": "$state1:example.org",
"sender": "@alice:example.org",
"type": "m.room.name",
"origin_server_ts": 1670000000000,
"state_key": "",
"content": {"name": "Test Room"},
}
]
},
}
}
},
"presence": {"events": []},
}
# ---------------------------------------------------------------------------
# Filter management
# ---------------------------------------------------------------------------
class TestCreateFilter:
@pytest.mark.asyncio
async def test_create_filter(self, mock_client) -> None:
filter_id = await create_filter(mock_client)
assert filter_id == "filter_id_123"
mock_client.create_filter.assert_called_once()
@pytest.mark.asyncio
async def test_create_filter_with_config(self, mock_client) -> None:
sf = SyncFilter(types=["m.room.message"], limit=10)
filter_id = await create_filter(mock_client, sf)
assert filter_id == "filter_id_123"
@pytest.mark.asyncio
async def test_create_filter_fallback_on_error(self, mock_client) -> None:
mock_client.create_filter.side_effect = Exception("Failed")
filter_id = await create_filter(mock_client)
assert filter_id == ""
# ---------------------------------------------------------------------------
# Sync operations
# ---------------------------------------------------------------------------
class TestSyncOnce:
@pytest.mark.asyncio
async def test_sync_initial(self, mock_client, sample_sync_response) -> None:
mock_client.sync.return_value = sample_sync_response
result = await sync_once(mock_client)
assert result.next_batch == "s72595_4483_1934"
assert "!room1:example.org" in result.rooms.get("join", {})
@pytest.mark.asyncio
async def test_sync_with_since(self, mock_client, sample_sync_response) -> None:
mock_client.sync.return_value = sample_sync_response
result = await sync_once(mock_client, since="s_prev")
assert result.next_batch == "s72595_4483_1934"
@pytest.mark.asyncio
async def test_sync_with_filter(self, mock_client, sample_sync_response) -> None:
mock_client.sync.return_value = sample_sync_response
result = await sync_once(mock_client, filter_id="f123")
assert result.next_batch != ""
@pytest.mark.asyncio
async def test_sync_failure(self, mock_client) -> None:
mock_client.sync.side_effect = Exception("Connection lost")
result = await sync_once(mock_client)
assert result.next_batch == ""
assert result.rooms == {}
class TestExtractEvents:
def test_extract_timeline_and_state(self, sample_sync_response) -> None:
sr = SyncResult(
next_batch="s123",
rooms=sample_sync_response["rooms"],
presence=sample_sync_response["presence"],
)
events = extract_events(sr)
# 2 timeline + 1 state = 3 events
assert len(events) == 3
def test_extract_event_properties(self, sample_sync_response) -> None:
sr = SyncResult(rooms=sample_sync_response["rooms"])
events = extract_events(sr)
msg_events = [e for e in events if e.event_type == "m.room.message"]
assert len(msg_events) == 2
assert msg_events[0].event_id == "$evt1:example.org"
assert msg_events[0].sender == "@alice:example.org"
assert msg_events[0].content["body"] == "Hello!"
def test_extract_state_event(self, sample_sync_response) -> None:
sr = SyncResult(rooms=sample_sync_response["rooms"])
events = extract_events(sr)
state_events = [e for e in events if e.event_type == "m.room.name"]
assert len(state_events) == 1
assert state_events[0].state_key == ""
assert state_events[0].content["name"] == "Test Room"
def test_extract_empty(self) -> None:
sr = SyncResult(rooms={})
events = extract_events(sr)
assert events == []
# ---------------------------------------------------------------------------
# Sync engine
# ---------------------------------------------------------------------------
class TestSyncEngine:
@pytest.fixture
def engine(self, mock_client):
return SyncEngine(mock_client, poll_timeout=1000)
def test_init_defaults(self, engine) -> None:
assert engine.running is False
assert engine.next_batch == ""
assert engine.total_events == 0
def test_register_handler(self, engine) -> None:
called = []
async def handler(event):
called.append(event)
engine.on_event("m.room.message", handler)
assert "m.room.message" in engine._event_type_handlers
assert len(engine._event_type_handlers["m.room.message"]) == 1
def test_register_room_handler(self, engine) -> None:
async def handler(event):
pass
engine.on_room("!room:example.org", handler)
assert "!room:example.org" in engine._room_handlers
def test_register_global_handler(self, engine) -> None:
async def handler(event):
pass
engine.on_any(handler)
assert len(engine._global_handlers) == 1
@pytest.mark.asyncio
async def test_start_background(self, engine) -> None:
task = engine.start_background()
assert task is not None
assert engine._task is task
# Clean up: stop the engine so the task doesn't run forever
await engine.stop()
@pytest.mark.asyncio
async def test_dispatch_to_event_type_handler(self, engine) -> None:
called = []
async def handler(event):
called.append(event)
engine.on_event("m.room.message", handler)
event = SyncEvent(
event_id="$e",
room_id="!r:example.org",
sender="@u:example.org",
event_type="m.room.message",
content={"body": "test"},
)
await engine._dispatch(event)
assert len(called) == 1
assert called[0].event_id == "$e"
@pytest.mark.asyncio
async def test_dispatch_to_room_handler(self, engine) -> None:
called = []
async def handler(event):
called.append(event)
engine.on_room("!room:example.org", handler)
event = SyncEvent(
event_id="$e",
room_id="!room:example.org",
sender="@u:example.org",
event_type="m.room.message",
content={"body": "test"},
)
await engine._dispatch(event)
assert len(called) == 1
@pytest.mark.asyncio
async def test_dispatch_to_global_handler(self, engine) -> None:
called = []
async def handler(event):
called.append(event)
engine.on_any(handler)
event = SyncEvent(
event_id="$e",
room_id="!r:example.org",
sender="@u:example.org",
event_type="m.room.member",
content={"membership": "join"},
)
await engine._dispatch(event)
assert len(called) == 1
@pytest.mark.asyncio
async def test_dispatch_handles_handler_exception(self, engine, caplog) -> None:
async def bad_handler(event):
raise RuntimeError("Handler failed")
engine.on_any(bad_handler)
event = SyncEvent(
event_id="$e",
room_id="!r:example.org",
sender="@u:example.org",
event_type="m.room.message",
content={},
)
# Should not raise
await engine._dispatch(event)
@pytest.mark.asyncio
async def test_stop_not_running(self, engine) -> None:
# Should not raise — no task running
await engine.stop()
# ---------------------------------------------------------------------------
# Message history
# ---------------------------------------------------------------------------
class TestGetMessages:
@pytest.mark.asyncio
async def test_get_messages_backward(self, mock_client) -> None:
mock_event = MagicMock()
mock_event.serialize.return_value = {"event_id": "$1", "type": "m.room.message"}
mock_result = MagicMock()
mock_result.events = [mock_event]
mock_result.start = "t1"
mock_result.end = "t2"
mock_client.get_messages.return_value = mock_result
result = await get_messages(mock_client, "!room:example.org", limit=10)
assert len(result.messages) == 1
assert result.start == "t1"
assert result.has_more is False # 1 < 10
@pytest.mark.asyncio
async def test_get_messages_has_more(self, mock_client) -> None:
mock_event = MagicMock()
mock_event.serialize.return_value = {"event_id": "$1"}
mock_result = MagicMock()
mock_result.events = [mock_event] * 20 # 20 events, limit=20
mock_result.start = "t1"
mock_result.end = "t2"
mock_client.get_messages.return_value = mock_result
result = await get_messages(mock_client, "!room:example.org", limit=20)
assert len(result.messages) == 20
assert result.has_more is True
@pytest.mark.asyncio
async def test_get_messages_error(self, mock_client) -> None:
mock_client.get_messages.side_effect = Exception("Failed")
result = await get_messages(mock_client, "!room:example.org")
assert result.messages == []