""" Tests for services/matrix-bridge-dagi/app/ingress.py (M1.4 — egress + audit) Strategy: - mock MatrixClient sync_poll / send_text - mock httpx client for router invoke and audit write - verify: gateway invoked, send_text called with correct args - verify: dedupe prevents double-invoke - verify: audit events fire (received, replied, error) - verify: empty reply skips send_text (no spam) """ 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_router, _write_audit # 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}" ROUTER_URL = "http://dagi-router-node1:8000" HS_URL = "http://dagi-synapse-node1:8008" CONSOLE_URL = "http://dagi-sofiia-console-node1:8002" INTERNAL_TOKEN = "test_internal_token_xyz" TOKEN = "syt_fake_token" BOT_USER = "@dagi_bridge:daarion.space" USER = "@user:daarion.space" MSG_EVENT = { "type": "m.room.message", "event_id": "$event1:server", "sender": USER, "content": {"msgtype": "m.text", "body": "Привіт Sofiia!"}, "origin_server_ts": 1000, } 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, router_url=ROUTER_URL, node_id="NODA1", room_map=room_map, sofiia_console_url=CONSOLE_URL, sofiia_internal_token=INTERNAL_TOKEN, ) defaults.update(kwargs) return MatrixIngressLoop(**defaults) def _fake_sync(events: list) -> dict: return { "next_batch": "s_next", "rooms": {"join": {ROOM_ID: {"timeline": {"events": events}}}}, } def _ok_response(text: str = "Привіт! Я тут.") -> MagicMock: resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"response": text, "model": "test", "tokens_used": 100} resp.raise_for_status = MagicMock() return resp def _audit_response() -> MagicMock: resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"ok": True} resp.raise_for_status = MagicMock() return resp def _send_text_response() -> MagicMock: resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"event_id": "$reply:server"} resp.raise_for_status = MagicMock() return resp # ── _invoke_router ───────────────────────────────────────────────────────────── def test_invoke_router_correct_endpoint_and_field(): async def _inner(): captured = {} async def fake_post(url, *, json=None, timeout=None): captured["url"] = url captured["payload"] = json return _ok_response("pong!") client = MagicMock() client.post = fake_post result = await _invoke_router(client, ROUTER_URL, "sofiia", "NODA1", "ping", "session-1") assert "/v1/agents/sofiia/infer" in captured["url"] assert captured["payload"]["prompt"] == "ping" assert captured["payload"]["session_id"] == "session-1" assert result == "pong!" run(_inner()) def test_invoke_router_fallback_fields(): """Should pick up text/content/message if response key missing.""" async def _inner(): async def fake_post(url, *, json=None, timeout=None): resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"text": "hello from text field"} resp.raise_for_status = MagicMock() return resp client = MagicMock() client.post = fake_post result = await _invoke_router(client, ROUTER_URL, "sofiia", "NODA1", "hi", "s1") assert result == "hello from text field" run(_inner()) # ── _write_audit ─────────────────────────────────────────────────────────────── def test_write_audit_fires_to_console(): async def _inner(): captured = {} async def fake_post(url, *, json=None, headers=None, timeout=None): captured["url"] = url captured["headers"] = headers captured["json"] = json return _audit_response() client = MagicMock() client.post = fake_post await _write_audit( client, CONSOLE_URL, INTERNAL_TOKEN, event="matrix.message.received", agent_id="sofiia", node_id="NODA1", room_id=ROOM_ID, event_id="$e1", status="ok", ) assert "/api/audit/internal" in captured["url"] assert captured["headers"]["X-Internal-Service-Token"] == INTERNAL_TOKEN assert captured["json"]["event"] == "matrix.message.received" run(_inner()) def test_write_audit_no_op_when_no_token(): async def _inner(): called = [False] async def fake_post(*args, **kwargs): called[0] = True return _audit_response() client = MagicMock() client.post = fake_post # Empty token — should not call await _write_audit(client, CONSOLE_URL, "", "matrix.test", "sofiia", "NODA1", ROOM_ID, "$e1") assert not called[0] run(_inner()) def test_write_audit_never_raises(): async def _inner(): async def fake_post(*args, **kwargs): raise ConnectionError("audit server down") client = MagicMock() client.post = fake_post # Should not raise await _write_audit(client, CONSOLE_URL, INTERNAL_TOKEN, "matrix.test", "sofiia", "NODA1", ROOM_ID, "$e1") run(_inner()) # ── Full loop: ingress + egress + audit ──────────────────────────────────────── def test_loop_full_cycle_invoke_and_reply(): """One message → router invoked → send_text called with reply.""" async def _inner(): router_calls: List[Dict] = [] send_calls: List[Dict] = [] audit_calls: List[Dict] = [] stop = asyncio.Event() loop = _make_loop() 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([MSG_EVENT]) 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] async def fake_http_post(url, *, json=None, headers=None, timeout=None): if "/infer" in url: router_calls.append({"url": url, "json": json}) return _ok_response("Привіт! Я готова допомогти.") elif "/audit/internal" in url: audit_calls.append({"url": url, "json": json}) return _audit_response() return _audit_response() async def fake_send_text(room_id, text, txn_id): send_calls.append({"room_id": room_id, "text": text, "txn_id": txn_id}) return {"event_id": "$reply_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) mock_mc.sync_poll = fake_sync_poll mock_mc.join_room = AsyncMock() mock_mc.mark_seen = MagicMock() mock_mc.extract_room_messages = fake_extract mock_mc.send_text = fake_send_text # Patch MatrixClient.make_txn_id as static method MockClient.return_value = mock_mc MockClient.make_txn_id = lambda r, e: f"txn_{e}" 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_http_post MockHTTP.return_value = mock_http await asyncio.wait_for(loop.run(stop), timeout=3.0) # Router invoked once assert len(router_calls) == 1 assert "sofiia" in router_calls[0]["url"] assert router_calls[0]["json"]["prompt"] == "Привіт Sofiia!" # Reply sent once assert len(send_calls) == 1 assert send_calls[0]["room_id"] == ROOM_ID assert send_calls[0]["text"] == "Привіт! Я готова допомогти." # Audit events: at least received + replied audit_events = [a["json"]["event"] for a in audit_calls] assert "matrix.message.received" in audit_events assert "matrix.agent.replied" in audit_events run(_inner()) def test_loop_deduplication_no_double_invoke(): """Same event_id in two syncs → router called exactly once.""" async def _inner(): router_calls = [0] seen = set() stop = asyncio.Event() loop = _make_loop() call_count = [0] async def fake_sync_poll(**kwargs): call_count[0] += 1 if call_count[0] > 2: stop.set() return {"next_batch": "end", "rooms": {}} return _fake_sync([MSG_EVENT]) 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 e.get("event_id") not in seen] def fake_mark_seen(eid): seen.add(eid) async def fake_http_post(url, *, json=None, headers=None, timeout=None): if "/infer" in url: router_calls[0] += 1 return _ok_response("response") return _audit_response() 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 = fake_mark_seen mock_mc.extract_room_messages = fake_extract mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"}) MockClient.return_value = mock_mc MockClient.make_txn_id = lambda r, e: f"txn_{e}" 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_http_post MockHTTP.return_value = mock_http await asyncio.wait_for(loop.run(stop), timeout=3.0) assert router_calls[0] == 1 run(_inner()) def test_loop_empty_reply_skips_send(): """Empty reply from router → send_text NOT called.""" async def _inner(): send_called = [False] stop = asyncio.Event() loop = _make_loop() 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([MSG_EVENT]) 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] async def fake_http_post(url, *, json=None, headers=None, timeout=None): if "/infer" in url: resp = MagicMock() resp.status_code = 200 resp.json.return_value = {"response": ""} # empty resp.raise_for_status = MagicMock() return resp return _audit_response() async def fake_send_text(room_id, text, txn_id): send_called[0] = True return {"event_id": "$r"} 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.extract_room_messages = fake_extract mock_mc.send_text = fake_send_text MockClient.return_value = mock_mc MockClient.make_txn_id = lambda r, e: f"txn_{e}" 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_http_post MockHTTP.return_value = mock_http await asyncio.wait_for(loop.run(stop), timeout=3.0) assert not send_called[0] run(_inner()) def test_loop_metric_callbacks_fire(): """on_message_received and on_message_replied should be called.""" async def _inner(): received = [] replied = [] stop = asyncio.Event() loop = _make_loop( on_message_received=lambda r, a: received.append((r, a)), on_message_replied=lambda r, a, s: replied.append((r, a, s)), ) 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([MSG_EVENT]) 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] async def fake_http_post(url, *, json=None, headers=None, timeout=None): if "/infer" in url: return _ok_response("test reply") return _audit_response() 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.extract_room_messages = fake_extract mock_mc.send_text = AsyncMock(return_value={"event_id": "$r"}) MockClient.return_value = mock_mc MockClient.make_txn_id = lambda r, e: f"txn_{e}" 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_http_post MockHTTP.return_value = mock_http await asyncio.wait_for(loop.run(stop), timeout=3.0) assert len(received) == 1 assert received[0] == (ROOM_ID, "sofiia") assert len(replied) == 1 assert replied[0][2] == "ok" run(_inner())