Files
microdao-daarion/tests/test_matrix_bridge_client.py
Apple d8506da179 feat(matrix-bridge-dagi): add matrix client wrapper and synapse setup (PR-M1.1)
- adds MatrixClient with send_text/sync_poll/join_room/whoami (idempotent via txn_id)
- LRU dedupe for incoming event_ids (2048 capacity)
- exponential backoff retry (max 3 attempts) for 429/5xx/network errors
- extract_room_messages: filters own messages, non-text, duplicates
- health endpoint now probes matrix_reachable + gateway_reachable at startup
- adds docker-compose.synapse-node1.yml (Synapse + Postgres for NODA1)
- adds ops/runbook-matrix-setup.md (10-step setup: DNS, config, bot, room, .env)
- 19 tests passing, no real Synapse required

Made-with: Cursor
2026-03-03 07:38:54 -08:00

357 lines
12 KiB
Python

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