"""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 == []