Files
microdao-daarion/tests/test_matrix_bridge_ingress.py
Apple dbfab78f02 feat(matrix-bridge-dagi): add room mapping, ingress loop, synapse setup (PR-M1.2 + PR-M1.3)
PR-M1.2 — room-to-agent mapping:
- adds room_mapping.py: parse BRIDGE_ROOM_MAP (format: agent:!room_id:server)
- RoomMappingConfig with O(1) room→agent lookup, agent allowlist check
- /bridge/mappings endpoint (read-only ops summary, no secrets)
- health endpoint now includes mappings_count
- 21 tests for parsing, validation, allowlist, summary

PR-M1.3 — Matrix ingress loop:
- adds ingress.py: MatrixIngressLoop asyncio task
- sync_poll → extract → dedupe → _invoke_gateway (POST /v1/invoke)
- gateway payload: agent_id, node_id, message, metadata (transport, room_id, event_id, sender)
- exponential backoff on errors (2s..60s)
- joins all mapped rooms at startup
- metric callbacks: on_message_received, on_gateway_error
- graceful shutdown via asyncio.Event
- 5 ingress tests (invoke, dedupe, callbacks, empty-map idle)

Synapse setup (docker-compose.synapse-node1.yml):
- fixed volume: bind mount ./synapse-data instead of named volume
- added port mapping 127.0.0.1:8008:8008

Synapse running on NODA1 (localhost:8008), bot @dagi_bridge:daarion.space created,
room !QwHczWXgefDHBEVkTH:daarion.space created, all 4 values in .env on NODA1.

Made-with: Cursor
2026-03-03 07:51:13 -08:00

352 lines
12 KiB
Python

"""
Tests for services/matrix-bridge-dagi/app/ingress.py
Strategy:
- mock MatrixClient.sync_poll and extract_room_messages
- mock httpx gateway client
- verify gateway is invoked once per unique message (dedupe works)
- verify stop_event terminates loop
"""
import asyncio
import sys
from pathlib import Path
from typing import Any, Dict, List
from unittest.mock import AsyncMock, MagicMock, patch, call
import pytest
_BRIDGE = Path(__file__).parent.parent / "services" / "matrix-bridge-dagi"
if str(_BRIDGE) not in sys.path:
sys.path.insert(0, str(_BRIDGE))
from app.ingress import MatrixIngressLoop, _invoke_gateway # noqa: E402
from app.room_mapping import parse_room_map # noqa: E402
def run(coro):
return asyncio.run(coro)
ALLOWED = frozenset({"sofiia"})
ROOM_ID = "!QwHczWXgefDHBEVkTH:daarion.space"
ROOM_MAP_STR = f"sofiia:{ROOM_ID}"
GW_URL = "http://127.0.0.1:9300"
HS_URL = "http://localhost:8008"
TOKEN = "syt_fake_token"
BOT_USER = "@dagi_bridge:daarion.space"
MOCK_EVENT_1 = {
"type": "m.room.message",
"event_id": "$event1:server",
"sender": "@user:server",
"content": {"msgtype": "m.text", "body": "Hello Sofiia!"},
"origin_server_ts": 1000,
}
MOCK_EVENT_2 = {
"type": "m.room.message",
"event_id": "$event2:server",
"sender": "@user:server",
"content": {"msgtype": "m.text", "body": "Another message"},
"origin_server_ts": 2000,
}
def _make_loop(**kwargs) -> MatrixIngressLoop:
room_map = parse_room_map(ROOM_MAP_STR, ALLOWED)
defaults = dict(
matrix_homeserver_url=HS_URL,
matrix_access_token=TOKEN,
matrix_user_id=BOT_USER,
gateway_url=GW_URL,
node_id="NODA1",
room_map=room_map,
)
defaults.update(kwargs)
return MatrixIngressLoop(**defaults)
def _fake_sync_resp(events: list) -> dict:
return {
"next_batch": "s_next",
"rooms": {
"join": {
ROOM_ID: {
"timeline": {"events": events}
}
}
}
}
# ── _invoke_gateway ─────────────────────────────────────────────────────────
def test_invoke_gateway_builds_correct_request():
async def _inner():
captured = {}
async def fake_post(url, *, json=None, timeout=None):
captured["url"] = url
captured["json"] = json
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"ok": True}
resp.raise_for_status = MagicMock()
return resp
client = MagicMock()
client.post = fake_post
result = await _invoke_gateway(
client, GW_URL, "sofiia", "NODA1",
"Hello!", ROOM_ID, "$event1", "@user:server"
)
assert "/v1/invoke" in captured["url"]
payload = captured["json"]
assert payload["agent_id"] == "sofiia"
assert payload["node_id"] == "NODA1"
assert payload["message"] == "Hello!"
meta = payload["metadata"]
assert meta["transport"] == "matrix"
assert meta["matrix_room_id"] == ROOM_ID
assert meta["matrix_event_id"] == "$event1"
assert meta["matrix_sender"] == "@user:server"
run(_inner())
# ── Ingress loop — normal flow ──────────────────────────────────────────────
def test_ingress_loop_invokes_gateway_once_per_message():
async def _inner():
received_calls: List[Dict] = []
async def fake_post(url, *, json=None, timeout=None):
received_calls.append({"url": url, "json": json})
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"ok": True}
resp.raise_for_status = MagicMock()
return resp
stop = asyncio.Event()
loop = _make_loop()
sync_responses = [
_fake_sync_resp([MOCK_EVENT_1]),
_fake_sync_resp([]), # empty on second poll
]
call_count = [0]
async def fake_sync_poll(**kwargs):
idx = call_count[0]
call_count[0] += 1
if idx >= len(sync_responses):
stop.set()
return {"next_batch": "end", "rooms": {}}
return sync_responses[idx]
with patch("app.ingress.MatrixClient") as MockClient:
mock_mc_instance = AsyncMock()
mock_mc_instance.__aenter__ = AsyncMock(return_value=mock_mc_instance)
mock_mc_instance.__aexit__ = AsyncMock(return_value=False)
mock_mc_instance.sync_poll = fake_sync_poll
mock_mc_instance.join_room = AsyncMock()
mock_mc_instance.mark_seen = MagicMock()
mock_mc_instance.is_duplicate = MagicMock(return_value=False)
def fake_extract(sync_resp, room_id):
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]
mock_mc_instance.extract_room_messages = fake_extract
MockClient.return_value = mock_mc_instance
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
mock_http = AsyncMock()
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
mock_http.__aexit__ = AsyncMock(return_value=False)
mock_http.post = fake_post
MockHTTP.return_value = mock_http
await asyncio.wait_for(loop.run(stop), timeout=3.0)
# Gateway should have been invoked exactly once (for MOCK_EVENT_1)
assert len(received_calls) == 1
assert received_calls[0]["json"]["message"] == "Hello Sofiia!"
run(_inner())
def test_ingress_loop_deduplicates_same_event():
"""Same event_id appearing twice should only invoke gateway once."""
async def _inner():
invoke_count = [0]
async def fake_post(url, *, json=None, timeout=None):
invoke_count[0] += 1
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"ok": True}
resp.raise_for_status = MagicMock()
return resp
stop = asyncio.Event()
loop = _make_loop()
# Same event in two consecutive syncs
sync_responses = [
_fake_sync_resp([MOCK_EVENT_1]),
_fake_sync_resp([MOCK_EVENT_1]), # duplicate event_id
]
call_count = [0]
seen = set()
async def fake_sync_poll(**kwargs):
idx = call_count[0]
call_count[0] += 1
if idx >= len(sync_responses):
stop.set()
return {"next_batch": "end", "rooms": {}}
return sync_responses[idx]
with patch("app.ingress.MatrixClient") as MockClient:
mock_mc_instance = AsyncMock()
mock_mc_instance.__aenter__ = AsyncMock(return_value=mock_mc_instance)
mock_mc_instance.__aexit__ = AsyncMock(return_value=False)
mock_mc_instance.sync_poll = fake_sync_poll
mock_mc_instance.join_room = AsyncMock()
def fake_mark_seen(event_id):
seen.add(event_id)
def fake_is_dup(event_id):
return event_id in seen
mock_mc_instance.mark_seen = fake_mark_seen
mock_mc_instance.is_duplicate = fake_is_dup
def fake_extract(sync_resp, room_id):
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
return [e for e in events if e.get("type") == "m.room.message"
and e.get("sender") != BOT_USER
and not fake_is_dup(e.get("event_id", ""))]
mock_mc_instance.extract_room_messages = fake_extract
MockClient.return_value = mock_mc_instance
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
mock_http = AsyncMock()
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
mock_http.__aexit__ = AsyncMock(return_value=False)
mock_http.post = fake_post
MockHTTP.return_value = mock_http
await asyncio.wait_for(loop.run(stop), timeout=3.0)
# Dedupe: only 1 invoke despite 2 sync responses with same event
assert invoke_count[0] == 1
run(_inner())
def test_ingress_loop_calls_metric_callbacks():
"""on_message_received and on_gateway_error callbacks should fire."""
async def _inner():
received_events = []
error_events = []
stop = asyncio.Event()
loop = _make_loop(
on_message_received=lambda room, agent: received_events.append((room, agent)),
on_gateway_error=lambda etype: error_events.append(etype),
)
call_count = [0]
async def fake_sync_poll(**kwargs):
call_count[0] += 1
if call_count[0] > 1:
stop.set()
return {"next_batch": "end", "rooms": {}}
return _fake_sync_resp([MOCK_EVENT_1])
with patch("app.ingress.MatrixClient") as MockClient:
mock_mc = AsyncMock()
mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
mock_mc.__aexit__ = AsyncMock(return_value=False)
mock_mc.sync_poll = fake_sync_poll
mock_mc.join_room = AsyncMock()
mock_mc.mark_seen = MagicMock()
mock_mc.is_duplicate = MagicMock(return_value=False)
def fake_extract(sync_resp, room_id):
events = sync_resp.get("rooms", {}).get("join", {}).get(room_id, {}).get("timeline", {}).get("events", [])
return [e for e in events if e.get("type") == "m.room.message" and e.get("sender") != BOT_USER]
mock_mc.extract_room_messages = fake_extract
MockClient.return_value = mock_mc
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
mock_http = AsyncMock()
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
mock_http.__aexit__ = AsyncMock(return_value=False)
async def fake_post(url, *, json=None, timeout=None):
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"ok": True}
resp.raise_for_status = MagicMock()
return resp
mock_http.post = fake_post
MockHTTP.return_value = mock_http
await asyncio.wait_for(loop.run(stop), timeout=3.0)
assert len(received_events) == 1
assert received_events[0] == (ROOM_ID, "sofiia")
run(_inner())
def test_ingress_loop_no_mappings_is_idle():
"""Loop with 0 mappings should start and stop cleanly without invoking gateway."""
async def _inner():
empty_map = parse_room_map("", ALLOWED)
loop = MatrixIngressLoop(
matrix_homeserver_url=HS_URL,
matrix_access_token=TOKEN,
matrix_user_id=BOT_USER,
gateway_url=GW_URL,
node_id="NODA1",
room_map=empty_map,
)
stop = asyncio.Event()
with patch("app.ingress.MatrixClient") as MockClient:
mock_mc = AsyncMock()
mock_mc.__aenter__ = AsyncMock(return_value=mock_mc)
mock_mc.__aexit__ = AsyncMock(return_value=False)
async def fake_sync_poll(**kwargs):
stop.set()
return {"next_batch": "end", "rooms": {}}
mock_mc.sync_poll = fake_sync_poll
MockClient.return_value = mock_mc
with patch("app.ingress.httpx.AsyncClient") as MockHTTP:
mock_http = AsyncMock()
mock_http.__aenter__ = AsyncMock(return_value=mock_http)
mock_http.__aexit__ = AsyncMock(return_value=False)
MockHTTP.return_value = mock_http
await asyncio.wait_for(loop.run(stop), timeout=3.0)
# Should complete without error
assert True
run(_inner())