diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 4a1cd2e9e..409d2d6e4 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -35,18 +35,54 @@ from typing import Any, Dict, Optional, Set from html import escape as _html_escape -from mautrix.types import ( - ContentURI, - EventID, - EventType, - PaginationDirection, - PresenceState, - RoomCreatePreset, - RoomID, - SyncToken, - TrustState, - UserID, -) +try: + from mautrix.types import ( + ContentURI, + EventID, + EventType, + PaginationDirection, + PresenceState, + RoomCreatePreset, + RoomID, + SyncToken, + TrustState, + UserID, + ) +except ImportError: + # Stubs so the module is importable without mautrix installed. + # check_matrix_requirements() will return False and the adapter + # won't be instantiated in production, but tests may exercise + # adapter methods so stubs must have the right attributes. + ContentURI = EventID = RoomID = SyncToken = UserID = str # type: ignore[misc,assignment] + + class _EventTypeStub: # type: ignore[no-redef] + ROOM_MESSAGE = "m.room.message" + REACTION = "m.reaction" + ROOM_ENCRYPTED = "m.room.encrypted" + ROOM_NAME = "m.room.name" + EventType = _EventTypeStub # type: ignore[misc,assignment] + + class _PaginationDirectionStub: # type: ignore[no-redef] + BACKWARD = "b" + FORWARD = "f" + PaginationDirection = _PaginationDirectionStub # type: ignore[misc,assignment] + + class _PresenceStateStub: # type: ignore[no-redef] + ONLINE = "online" + OFFLINE = "offline" + UNAVAILABLE = "unavailable" + PresenceState = _PresenceStateStub # type: ignore[misc,assignment] + + class _RoomCreatePresetStub: # type: ignore[no-redef] + PRIVATE = "private_chat" + PUBLIC = "public_chat" + TRUSTED_PRIVATE = "trusted_private_chat" + RoomCreatePreset = _RoomCreatePresetStub # type: ignore[misc,assignment] + + class _TrustStateStub: # type: ignore[no-redef] + UNVERIFIED = 0 + VERIFIED = 1 + TrustState = _TrustStateStub # type: ignore[misc,assignment] from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index 5c79e476b..469bae030 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -601,6 +601,40 @@ class TestMatrixDisplayName: # Requirements check # --------------------------------------------------------------------------- +class TestMatrixModuleImport: + def test_module_importable_without_mautrix(self): + """gateway.platforms.matrix must be importable even when mautrix is + not installed — otherwise the gateway crashes for ALL platforms. + + This test uses a subprocess to avoid polluting the current process's + sys.modules (reimporting a module creates a second module object whose + classes don't share globals with the original — breaking patch.object + in subsequent tests). + """ + import subprocess + result = subprocess.run( + [sys.executable, "-c", ( + "import sys\n" + "# Block mautrix completely\n" + "class _Blocker:\n" + " def find_module(self, name, path=None):\n" + " if name.startswith('mautrix'): return self\n" + " def load_module(self, name):\n" + " raise ImportError(f'blocked: {name}')\n" + "sys.meta_path.insert(0, _Blocker())\n" + "for k in list(sys.modules):\n" + " if k.startswith('mautrix'): del sys.modules[k]\n" + "from gateway.platforms.matrix import check_matrix_requirements\n" + "assert not check_matrix_requirements()\n" + "print('OK')\n" + )], + capture_output=True, text=True, timeout=10, + ) + assert result.returncode == 0, ( + f"Subprocess failed:\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + class TestMatrixRequirements: def test_check_requirements_with_token(self, monkeypatch): monkeypatch.setenv("MATRIX_ACCESS_TOKEN", "syt_test") @@ -738,7 +772,7 @@ class TestMatrixE2EEHardFail: @pytest.mark.asyncio async def test_connect_fails_when_encryption_true_but_no_e2ee_deps(self): - from gateway.platforms.matrix import MatrixAdapter + from gateway.platforms.matrix import MatrixAdapter, _check_e2ee_deps config = PlatformConfig( enabled=True, @@ -768,7 +802,8 @@ class TestMatrixE2EEHardFail: from gateway.platforms import matrix as matrix_mod with patch.object(matrix_mod, "_check_e2ee_deps", return_value=False): with patch.dict("sys.modules", fake_mautrix_mods): - result = await adapter.connect() + with patch.object(adapter, "_sync_loop", AsyncMock(return_value=None)): + result = await adapter.connect() assert result is False diff --git a/tests/gateway/test_matrix_mention.py b/tests/gateway/test_matrix_mention.py index c0533741a..d36c2b765 100644 --- a/tests/gateway/test_matrix_mention.py +++ b/tests/gateway/test_matrix_mention.py @@ -11,59 +11,10 @@ import pytest from gateway.config import PlatformConfig -def _ensure_mautrix_mock(): - """Install mock mautrix modules when mautrix-python isn't available.""" - if "mautrix" in sys.modules and hasattr(sys.modules["mautrix"], "__file__"): - return - - # Root module - mautrix_mod = MagicMock() - - # mautrix.types — commonly imported types - types_mod = MagicMock() - types_mod.EventType = MagicMock() - types_mod.RoomID = str - types_mod.UserID = str - types_mod.EventID = str - types_mod.ContentURI = str - types_mod.RoomCreatePreset = MagicMock() - types_mod.PresenceState = MagicMock() - types_mod.PaginationDirection = MagicMock() - types_mod.SyncToken = str - types_mod.TrustState = MagicMock() - - # mautrix.client - client_mod = MagicMock() - client_mod.Client = MagicMock() - client_mod.InternalEventType = MagicMock() - - # mautrix.client.state_store - state_store_mod = MagicMock() - state_store_mod.MemoryStateStore = MagicMock() - state_store_mod.MemorySyncStore = MagicMock() - - # mautrix.api - api_mod = MagicMock() - api_mod.HTTPAPI = MagicMock() - - # mautrix.crypto - crypto_mod = MagicMock() - crypto_mod.OlmMachine = MagicMock() - crypto_store_mod = MagicMock() - crypto_store_mod.MemoryCryptoStore = MagicMock() - crypto_attachments_mod = MagicMock() - - sys.modules.setdefault("mautrix", mautrix_mod) - sys.modules.setdefault("mautrix.types", types_mod) - sys.modules.setdefault("mautrix.client", client_mod) - sys.modules.setdefault("mautrix.client.state_store", state_store_mod) - sys.modules.setdefault("mautrix.api", api_mod) - sys.modules.setdefault("mautrix.crypto", crypto_mod) - sys.modules.setdefault("mautrix.crypto.store", crypto_store_mod) - sys.modules.setdefault("mautrix.crypto.attachments", crypto_attachments_mod) - - -_ensure_mautrix_mock() +# The matrix adapter module is importable without mautrix installed +# (module-level imports use try/except with stubs). No need for +# module-level mock installation — tests that call adapter methods +# needing real mautrix APIs mock them individually. def _make_adapter(tmp_path=None): @@ -410,8 +361,9 @@ async def test_auto_thread_tracks_participation(monkeypatch): class TestThreadPersistence: def test_empty_state_file(self, tmp_path, monkeypatch): """No state file → empty set.""" + from gateway.platforms.matrix import MatrixAdapter monkeypatch.setattr( - "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + MatrixAdapter, "_thread_state_path", staticmethod(lambda: tmp_path / "matrix_threads.json"), ) adapter = _make_adapter() @@ -420,9 +372,10 @@ class TestThreadPersistence: def test_track_thread_persists(self, tmp_path, monkeypatch): """_track_thread writes to disk.""" + from gateway.platforms.matrix import MatrixAdapter state_path = tmp_path / "matrix_threads.json" monkeypatch.setattr( - "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + MatrixAdapter, "_thread_state_path", staticmethod(lambda: state_path), ) adapter = _make_adapter() @@ -433,10 +386,11 @@ class TestThreadPersistence: def test_threads_survive_reload(self, tmp_path, monkeypatch): """Persisted threads are loaded by a new adapter instance.""" + from gateway.platforms.matrix import MatrixAdapter state_path = tmp_path / "matrix_threads.json" state_path.write_text(json.dumps(["$t1", "$t2"])) monkeypatch.setattr( - "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + MatrixAdapter, "_thread_state_path", staticmethod(lambda: state_path), ) adapter = _make_adapter() @@ -445,9 +399,10 @@ class TestThreadPersistence: def test_cap_max_tracked_threads(self, tmp_path, monkeypatch): """Thread set is trimmed to _MAX_TRACKED_THREADS.""" + from gateway.platforms.matrix import MatrixAdapter state_path = tmp_path / "matrix_threads.json" monkeypatch.setattr( - "gateway.platforms.matrix.MatrixAdapter._thread_state_path", + MatrixAdapter, "_thread_state_path", staticmethod(lambda: state_path), ) adapter = _make_adapter()