/ tests / gateway / test_matrix_voice.py
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)