3ddf8170f8
- 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).
435 lines
14 KiB
Python
435 lines
14 KiB
Python
"""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 == []
|