test_matrix_voice.py
1 """Tests for Matrix voice message support (MSC3245). 2 3 Updated for the mautrix-python SDK (no more matrix-nio / nio imports). 4 """ 5 import io 6 import os 7 import tempfile 8 import types 9 from types import SimpleNamespace 10 11 import pytest 12 from unittest.mock import AsyncMock, MagicMock, patch 13 14 # Try importing mautrix; skip entire file if not available. 15 try: 16 import mautrix as _mautrix_probe 17 if not isinstance(_mautrix_probe, types.ModuleType) or not hasattr(_mautrix_probe, "__file__"): 18 pytest.skip("mautrix in sys.modules is a mock, not the real package", allow_module_level=True) 19 except ImportError: 20 pytest.skip("mautrix not installed", allow_module_level=True) 21 22 from gateway.platforms.base import MessageType 23 24 25 # --------------------------------------------------------------------------- 26 # Adapter helpers 27 # --------------------------------------------------------------------------- 28 29 def _make_adapter(): 30 """Create a MatrixAdapter with mocked config.""" 31 from gateway.platforms.matrix import MatrixAdapter 32 from gateway.config import PlatformConfig 33 34 config = PlatformConfig( 35 enabled=True, 36 token="***", 37 extra={ 38 "homeserver": "https://matrix.example.org", 39 "user_id": "@bot:example.org", 40 }, 41 ) 42 adapter = MatrixAdapter(config) 43 return adapter 44 45 46 def _make_audio_event( 47 event_id: str = "$audio_event", 48 sender: str = "@alice:example.org", 49 room_id: str = "!test:example.org", 50 body: str = "Voice message", 51 url: str = "mxc://example.org/abc123", 52 is_voice: bool = False, 53 mimetype: str = "audio/ogg", 54 timestamp: int = 9999999999000, # ms 55 ): 56 """ 57 Create a mock mautrix room message event. 58 59 In mautrix, the handler receives a single event object with attributes 60 ``room_id``, ``sender``, ``event_id``, ``timestamp``, and ``content`` 61 (a dict-like or serializable object). 62 63 Args: 64 is_voice: If True, adds org.matrix.msc3245.voice field to content. 65 """ 66 content = { 67 "msgtype": "m.audio", 68 "body": body, 69 "url": url, 70 "info": { 71 "mimetype": mimetype, 72 }, 73 } 74 75 if is_voice: 76 content["org.matrix.msc3245.voice"] = {} 77 78 event = SimpleNamespace( 79 event_id=event_id, 80 sender=sender, 81 room_id=room_id, 82 timestamp=timestamp, 83 content=content, 84 ) 85 return event 86 87 88 def _make_state_store(member_count: int = 2): 89 """Create a mock state store with get_members/get_member support.""" 90 store = MagicMock() 91 # get_members returns a list of member user IDs 92 members = [MagicMock() for _ in range(member_count)] 93 store.get_members = AsyncMock(return_value=members) 94 # get_member returns a single member info object 95 member = MagicMock() 96 member.displayname = "Alice" 97 store.get_member = AsyncMock(return_value=member) 98 return store 99 100 101 # --------------------------------------------------------------------------- 102 # Tests: MSC3245 Voice Detection 103 # --------------------------------------------------------------------------- 104 105 class TestMatrixVoiceMessageDetection: 106 """Test that MSC3245 voice messages are detected and tagged correctly.""" 107 108 def setup_method(self): 109 self.adapter = _make_adapter() 110 self.adapter._user_id = "@bot:example.org" 111 self.adapter._startup_ts = 0.0 112 self.adapter._dm_rooms = {} 113 self.adapter._message_handler = AsyncMock() 114 # Mock _mxc_to_http to return a fake HTTP URL 115 self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}" 116 # Mock client for authenticated download — download_media returns bytes directly 117 self.adapter._client = MagicMock() 118 self.adapter._client.download_media = AsyncMock(return_value=b"fake audio data") 119 # State store for DM detection 120 self.adapter._client.state_store = _make_state_store() 121 122 @pytest.mark.asyncio 123 async def test_voice_message_has_type_voice(self): 124 """Voice messages (with MSC3245 field) should be MessageType.VOICE.""" 125 event = _make_audio_event(is_voice=True) 126 127 # Capture the MessageEvent passed to handle_message 128 captured_event = None 129 130 async def capture(msg_event): 131 nonlocal captured_event 132 captured_event = msg_event 133 134 self.adapter.handle_message = capture 135 136 await self.adapter._on_room_message(event) 137 138 assert captured_event is not None, "No event was captured" 139 assert captured_event.message_type == MessageType.VOICE, \ 140 f"Expected MessageType.VOICE, got {captured_event.message_type}" 141 142 @pytest.mark.asyncio 143 async def test_voice_message_has_local_path(self): 144 """Voice messages should have a local cached path in media_urls.""" 145 event = _make_audio_event(is_voice=True) 146 147 captured_event = None 148 149 async def capture(msg_event): 150 nonlocal captured_event 151 captured_event = msg_event 152 153 self.adapter.handle_message = capture 154 155 await self.adapter._on_room_message(event) 156 157 assert captured_event is not None 158 assert captured_event.media_urls is not None 159 assert len(captured_event.media_urls) > 0 160 # Should be a local path, not an HTTP URL 161 assert not captured_event.media_urls[0].startswith("http"), \ 162 f"media_urls should contain local path, got {captured_event.media_urls[0]}" 163 # download_media is called with a ContentURI wrapping the mxc URL 164 self.adapter._client.download_media.assert_awaited_once() 165 assert captured_event.media_types == ["audio/ogg"] 166 167 @pytest.mark.asyncio 168 async def test_audio_without_msc3245_stays_audio_type(self): 169 """Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO.""" 170 event = _make_audio_event(is_voice=False) # NOT a voice message 171 172 captured_event = None 173 174 async def capture(msg_event): 175 nonlocal captured_event 176 captured_event = msg_event 177 178 self.adapter.handle_message = capture 179 180 await self.adapter._on_room_message(event) 181 182 assert captured_event is not None 183 assert captured_event.message_type == MessageType.AUDIO, \ 184 f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}" 185 186 @pytest.mark.asyncio 187 async def test_regular_audio_is_cached_locally(self): 188 """Regular audio uploads are cached locally for downstream tool access. 189 190 Since PR #bec02f37 (encrypted-media caching refactor), all media 191 types — photo, audio, video, document — are cached locally when 192 received so tools can read them as real files. This applies equally 193 to voice messages and regular audio. 194 """ 195 event = _make_audio_event(is_voice=False) 196 197 captured_event = None 198 199 async def capture(msg_event): 200 nonlocal captured_event 201 captured_event = msg_event 202 203 self.adapter.handle_message = capture 204 205 await self.adapter._on_room_message(event) 206 207 assert captured_event is not None 208 assert captured_event.media_urls is not None 209 # Should be a local path, not an HTTP URL. 210 assert not captured_event.media_urls[0].startswith("http"), \ 211 f"Regular audio should be cached locally, got {captured_event.media_urls[0]}" 212 self.adapter._client.download_media.assert_awaited_once() 213 assert captured_event.media_types == ["audio/ogg"] 214 215 216 class TestMatrixVoiceCacheFallback: 217 """Test graceful fallback when voice caching fails.""" 218 219 def setup_method(self): 220 self.adapter = _make_adapter() 221 self.adapter._user_id = "@bot:example.org" 222 self.adapter._startup_ts = 0.0 223 self.adapter._dm_rooms = {} 224 self.adapter._message_handler = AsyncMock() 225 self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}" 226 self.adapter._client = MagicMock() 227 self.adapter._client.state_store = _make_state_store() 228 229 @pytest.mark.asyncio 230 async def test_voice_cache_failure_falls_back_to_http_url(self): 231 """If caching fails (download returns None), voice message should still be delivered with HTTP URL.""" 232 event = _make_audio_event(is_voice=True) 233 234 # download_media returns None on failure 235 self.adapter._client.download_media = AsyncMock(return_value=None) 236 237 captured_event = None 238 239 async def capture(msg_event): 240 nonlocal captured_event 241 captured_event = msg_event 242 243 self.adapter.handle_message = capture 244 245 await self.adapter._on_room_message(event) 246 247 assert captured_event is not None 248 assert captured_event.media_urls is not None 249 # Should fall back to HTTP URL 250 assert captured_event.media_urls[0].startswith("http"), \ 251 f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}" 252 253 @pytest.mark.asyncio 254 async def test_voice_cache_exception_falls_back_to_http_url(self): 255 """Unexpected download exceptions should also fall back to HTTP URL.""" 256 event = _make_audio_event(is_voice=True) 257 258 self.adapter._client.download_media = AsyncMock(side_effect=RuntimeError("boom")) 259 260 captured_event = None 261 262 async def capture(msg_event): 263 nonlocal captured_event 264 captured_event = msg_event 265 266 self.adapter.handle_message = capture 267 268 await self.adapter._on_room_message(event) 269 270 assert captured_event is not None 271 assert captured_event.media_urls is not None 272 assert captured_event.media_urls[0].startswith("http"), \ 273 f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}" 274 275 276 # --------------------------------------------------------------------------- 277 # Tests: send_voice includes MSC3245 field 278 # --------------------------------------------------------------------------- 279 280 class TestMatrixSendVoiceMSC3245: 281 """Test that send_voice includes MSC3245 field for native voice rendering.""" 282 283 def setup_method(self): 284 self.adapter = _make_adapter() 285 self.adapter._user_id = "@bot:example.org" 286 # Mock client — upload_media returns a ContentURI string 287 self.adapter._client = MagicMock() 288 self.upload_call = None 289 290 async def mock_upload_media(data, mime_type=None, filename=None, **kwargs): 291 self.upload_call = {"data": data, "mime_type": mime_type, "filename": filename} 292 return "mxc://example.org/uploaded" 293 294 self.adapter._client.upload_media = mock_upload_media 295 296 @pytest.mark.asyncio 297 @patch("mimetypes.guess_type", return_value=("audio/ogg", None)) 298 async def test_send_voice_includes_msc3245_field(self, _mock_guess): 299 """send_voice should include org.matrix.msc3245.voice in message content.""" 300 # Create a temp audio file 301 with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f: 302 f.write(b"fake audio data") 303 temp_path = f.name 304 305 try: 306 # Capture the message content sent via send_message_event 307 sent_content = None 308 309 async def mock_send_message_event(room_id, event_type, content): 310 nonlocal sent_content 311 sent_content = content 312 # send_message_event returns an EventID string 313 return "$sent_event" 314 315 self.adapter._client.send_message_event = mock_send_message_event 316 317 await self.adapter.send_voice( 318 chat_id="!room:example.org", 319 audio_path=temp_path, 320 caption="Test voice", 321 ) 322 323 assert sent_content is not None, "No message was sent" 324 assert "org.matrix.msc3245.voice" in sent_content, \ 325 f"MSC3245 voice field missing from content: {sent_content.keys()}" 326 assert sent_content["msgtype"] == "m.audio" 327 assert sent_content["info"]["mimetype"] == "audio/ogg" 328 assert self.upload_call is not None, "Expected upload_media() to be called" 329 assert isinstance(self.upload_call["data"], bytes) 330 assert self.upload_call["mime_type"] == "audio/ogg" 331 assert self.upload_call["filename"].endswith(".ogg") 332 333 finally: 334 os.unlink(temp_path)