- 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
357 lines
12 KiB
Python
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())
|