""" Tests for services/matrix-bridge-dagi/app/matrix_client.py Uses monkeypatching of httpx.AsyncClient — no real Synapse required. """ import asyncio import hashlib import sys from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest # Make the bridge app importable without installing _BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi" if str(_BRIDGE) not in sys.path: sys.path.insert(0, str(_BRIDGE)) from app.matrix_client import MatrixClient, _LRUSet # noqa: E402 def run(coro): """Helper: run async coroutine in sync test.""" return asyncio.run(coro) # ── Helpers ──────────────────────────────────────────────────────────────────── HS = "https://matrix.example.com" TOKEN = "syt_test_token_abc123" BOT = "@dagi_bridge:example.com" ROOM = "!testroom:example.com" def _make_client() -> MatrixClient: return MatrixClient(hs=HS, access_token=TOKEN, bot_user_id=BOT) # type: ignore[call-arg] def _make_client_direct() -> MatrixClient: return MatrixClient(HS, TOKEN, BOT) def _fake_resp(status_code: int, body: dict) -> MagicMock: resp = MagicMock() resp.status_code = status_code resp.json.return_value = body resp.raise_for_status = MagicMock() if status_code >= 400: from httpx import HTTPStatusError, Request, Response resp.raise_for_status.side_effect = HTTPStatusError( "error", request=MagicMock(), response=resp ) return resp # ── LRUSet tests ─────────────────────────────────────────────────────────────── def test_lru_set_basic(): s = _LRUSet(maxsize=3) assert not s.contains("a") s.add("a") assert s.contains("a") def test_lru_set_eviction(): s = _LRUSet(maxsize=2) s.add("a") s.add("b") s.add("c") # evicts "a" assert not s.contains("a") assert s.contains("b") assert s.contains("c") def test_lru_set_moves_to_end_on_access(): s = _LRUSet(maxsize=2) s.add("a") s.add("b") s.contains("a") # refreshes "a" s.add("c") # evicts "b", not "a" assert s.contains("a") assert not s.contains("b") assert s.contains("c") # ── make_txn_id ──────────────────────────────────────────────────────────────── def test_make_txn_id_deterministic(): tid1 = MatrixClient.make_txn_id(ROOM, "$event1") tid2 = MatrixClient.make_txn_id(ROOM, "$event1") assert tid1 == tid2 assert len(tid1) == 32 # first 32 hex chars of sha256 def test_make_txn_id_different_events(): tid1 = MatrixClient.make_txn_id(ROOM, "$event1") tid2 = MatrixClient.make_txn_id(ROOM, "$event2") assert tid1 != tid2 def test_make_txn_id_different_rooms(): tid1 = MatrixClient.make_txn_id("!room1:x", "$event1") tid2 = MatrixClient.make_txn_id("!room2:x", "$event1") assert tid1 != tid2 # ── Context manager guard ────────────────────────────────────────────────────── def test_client_not_initialised_raises(): async def _inner(): client = _make_client_direct() with pytest.raises(RuntimeError, match="not initialised"): await client.whoami() run(_inner()) # ── whoami ───────────────────────────────────────────────────────────────────── def test_whoami_success(): async def _inner(): client = _make_client_direct() async with client: client._client.request = AsyncMock( return_value=_fake_resp(200, {"user_id": BOT, "device_id": "DAGI1"}) ) result = await client.whoami() assert result["user_id"] == BOT run(_inner()) # ── join_room ────────────────────────────────────────────────────────────────── def test_join_room_success(): async def _inner(): client = _make_client_direct() async with client: client._client.request = AsyncMock( return_value=_fake_resp(200, {"room_id": ROOM}) ) result = await client.join_room(ROOM) assert result["room_id"] == ROOM run(_inner()) # ── send_text ────────────────────────────────────────────────────────────────── def test_send_text_success(): async def _inner(): client = _make_client_direct() event_id = "$out_event1" txn_id = MatrixClient.make_txn_id(ROOM, "$source_event1") async with client: client._client.request = AsyncMock( return_value=_fake_resp(200, {"event_id": event_id}) ) result = await client.send_text(ROOM, "Hello DAGI!", txn_id) assert result["event_id"] == event_id run(_inner()) def test_send_text_uses_correct_method_and_path(): async def _inner(): client = _make_client_direct() txn_id = MatrixClient.make_txn_id(ROOM, "$src") captured = {} async def fake_request(method, url, **kwargs): captured["method"] = method captured["url"] = url captured["json"] = kwargs.get("json") return _fake_resp(200, {"event_id": "$out"}) async with client: client._client.request = fake_request await client.send_text(ROOM, "test", txn_id) assert captured["method"] == "PUT" assert "send/m.room.message" in captured["url"] assert txn_id in captured["url"] assert captured["json"]["msgtype"] == "m.text" assert captured["json"]["body"] == "test" run(_inner()) # ── sync_poll ────────────────────────────────────────────────────────────────── def test_sync_poll_initial(): async def _inner(): client = _make_client_direct() sync_data = { "next_batch": "s1_token", "rooms": {"join": {ROOM: {"timeline": {"events": []}}}} } async with client: client._client.request = AsyncMock( return_value=_fake_resp(200, sync_data) ) result = await client.sync_poll() assert result["next_batch"] == "s1_token" run(_inner()) def test_sync_poll_passes_since(): async def _inner(): client = _make_client_direct() captured = {} async def fake_request(method, url, **kwargs): captured["params"] = kwargs.get("params", {}) return _fake_resp(200, {"next_batch": "s2"}) async with client: client._client.request = fake_request await client.sync_poll(since="s1_token") assert captured["params"].get("since") == "s1_token" run(_inner()) # ── extract_room_messages ────────────────────────────────────────────────────── def _make_sync_with_events(events: list) -> dict: return { "next_batch": "s_test", "rooms": { "join": { ROOM: { "timeline": { "events": events } } } } } def test_extract_filters_own_messages(): client = _make_client_direct() sync = _make_sync_with_events([ { "type": "m.room.message", "event_id": "$e1", "sender": BOT, # own message "content": {"msgtype": "m.text", "body": "I said this"}, "origin_server_ts": 1000, } ]) msgs = client.extract_room_messages(sync, ROOM) assert msgs == [] def test_extract_filters_non_text(): client = _make_client_direct() sync = _make_sync_with_events([ { "type": "m.room.message", "event_id": "$e2", "sender": "@user:example.com", "content": {"msgtype": "m.image", "url": "mxc://..."}, "origin_server_ts": 1000, } ]) msgs = client.extract_room_messages(sync, ROOM) assert msgs == [] def test_extract_filters_duplicate_events(): client = _make_client_direct() event = { "type": "m.room.message", "event_id": "$e3", "sender": "@user:example.com", "content": {"msgtype": "m.text", "body": "hello"}, "origin_server_ts": 1000, } sync = _make_sync_with_events([event]) client.mark_seen("$e3") msgs = client.extract_room_messages(sync, ROOM) assert msgs == [] def test_extract_returns_new_text_messages(): client = _make_client_direct() events = [ { "type": "m.room.message", "event_id": "$e4", "sender": "@user:example.com", "content": {"msgtype": "m.text", "body": "What is AgroMatrix?"}, "origin_server_ts": 2000, }, { "type": "m.room.message", "event_id": "$e5", "sender": "@user2:example.com", "content": {"msgtype": "m.text", "body": "Hello!"}, "origin_server_ts": 3000, }, ] sync = _make_sync_with_events(events) msgs = client.extract_room_messages(sync, ROOM) assert len(msgs) == 2 assert msgs[0]["event_id"] == "$e4" assert msgs[1]["event_id"] == "$e5" def test_extract_ignores_other_room(): client = _make_client_direct() other_room = "!otherroom:example.com" sync = { "next_batch": "s_x", "rooms": { "join": { other_room: { "timeline": { "events": [{ "type": "m.room.message", "event_id": "$e6", "sender": "@user:example.com", "content": {"msgtype": "m.text", "body": "hi"}, "origin_server_ts": 1000, }] } } } } } msgs = client.extract_room_messages(sync, ROOM) assert msgs == [] # ── Retry on 429 ─────────────────────────────────────────────────────────────── def test_retry_on_rate_limit(): async def _inner(): client = _make_client_direct() call_count = 0 async def fake_request(method, url, **kwargs): nonlocal call_count call_count += 1 if call_count < 3: return _fake_resp(429, {"retry_after_ms": 10}) return _fake_resp(200, {"user_id": BOT}) async with client: client._client.request = fake_request with patch("asyncio.sleep", new_callable=AsyncMock): result = await client.whoami() assert result["user_id"] == BOT assert call_count == 3 run(_inner())