2026-04-11 06:58:32 +05:30
|
|
|
"""Tests for Matrix voice message support (MSC3245).
|
|
|
|
|
|
|
|
|
|
Updated for the mautrix-python SDK (no more matrix-nio / nio imports).
|
|
|
|
|
"""
|
2026-03-30 00:02:51 -07:00
|
|
|
import io
|
2026-04-11 06:58:32 +05:30
|
|
|
import os
|
|
|
|
|
import tempfile
|
fix: repair 57 failing CI tests across 14 files (#5823)
* fix: repair 57 failing CI tests across 14 files
Categories of fixes:
**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
model resolution works regardless of xdist worker state
**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
(importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing
**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
(default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
(new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
so _find_skill searches tmp_path, not real ~/.hermes/skills/
**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
delivery_router to _make_runner()
**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
evicted by the stale-detection logic because sentinels have no
get_activity_summary() method, causing _stale_idle=inf >= timeout.
Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.
* fix: address remaining CI failures
- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
fooled by MagicMock in sys.modules under xdist)
2026-04-07 09:58:45 -07:00
|
|
|
import types
|
2026-04-11 06:58:32 +05:30
|
|
|
from types import SimpleNamespace
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
import pytest
|
fix: repair 57 failing CI tests across 14 files (#5823)
* fix: repair 57 failing CI tests across 14 files
Categories of fixes:
**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
model resolution works regardless of xdist worker state
**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
(importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing
**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
(default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
(new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
so _find_skill searches tmp_path, not real ~/.hermes/skills/
**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
delivery_router to _make_runner()
**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
evicted by the stale-detection logic because sentinels have no
get_activity_summary() method, causing _stale_idle=inf >= timeout.
Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.
* fix: address remaining CI failures
- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
fooled by MagicMock in sys.modules under xdist)
2026-04-07 09:58:45 -07:00
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
|
|
2026-04-11 06:58:32 +05:30
|
|
|
# Try importing mautrix; skip entire file if not available.
|
fix: repair 57 failing CI tests across 14 files (#5823)
* fix: repair 57 failing CI tests across 14 files
Categories of fixes:
**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
model resolution works regardless of xdist worker state
**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
(importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing
**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
(default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
(new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
so _find_skill searches tmp_path, not real ~/.hermes/skills/
**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
delivery_router to _make_runner()
**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
evicted by the stale-detection logic because sentinels have no
get_activity_summary() method, causing _stale_idle=inf >= timeout.
Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.
* fix: address remaining CI failures
- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
fooled by MagicMock in sys.modules under xdist)
2026-04-07 09:58:45 -07:00
|
|
|
try:
|
2026-04-11 06:58:32 +05:30
|
|
|
import mautrix as _mautrix_probe
|
|
|
|
|
if not isinstance(_mautrix_probe, types.ModuleType) or not hasattr(_mautrix_probe, "__file__"):
|
|
|
|
|
pytest.skip("mautrix in sys.modules is a mock, not the real package", allow_module_level=True)
|
fix: repair 57 failing CI tests across 14 files (#5823)
* fix: repair 57 failing CI tests across 14 files
Categories of fixes:
**Test isolation under xdist (-n auto):**
- test_hermes_logging: Strip ALL RotatingFileHandlers before each test
to prevent handlers leaked from other xdist workers from polluting counts
- test_code_execution: Force TERMINAL_ENV=local in setUp — prevents Modal
AuthError when another test leaks TERMINAL_ENV=modal
- test_timezone: Same TERMINAL_ENV fix for execute_code timezone tests
- test_codex_execution_paths: Mock _resolve_turn_agent_config to ensure
model resolution works regardless of xdist worker state
**Matrix adapter tests (nio not installed in CI):**
- Add _make_fake_nio() helper with real response classes for isinstance()
checks in production code
- Replace MagicMock(spec=nio.XxxResponse) with fake_nio instances
- Wrap production method calls with patch.dict('sys.modules', {'nio': ...})
so import nio succeeds in method bodies
- Use try/except instead of pytest.importorskip for nio.crypto imports
(importorskip can be fooled by MagicMock in sys.modules)
- test_matrix_voice: Skip entire file if nio is a mock, not just missing
**Stale test expectations:**
- test_cli_provider_resolution: _prompt_provider_choice now takes **kwargs
(default param added); mock getpass.getpass alongside input
- test_anthropic_oauth_flow: Mock getpass.getpass (code switched from input)
- test_gemini_provider: Mock models.dev + OpenRouter API lookups to test
hardcoded defaults without external API variance
- test_code_execution: Add notify_on_complete to blocked terminal params
- test_setup_openclaw_migration: Mock prompt_choice to select 'Full setup'
(new quick-setup path leads to _require_tty → sys.exit in CI)
- test_skill_manager_tool: Patch get_all_skills_dirs alongside SKILLS_DIR
so _find_skill searches tmp_path, not real ~/.hermes/skills/
**Missing attributes in object.__new__ test runners:**
- test_platform_reconnect: Add session_store to _make_runner()
- test_session_race_guard: Add hooks, _running_agents_ts, session_store,
delivery_router to _make_runner()
**Production bug fix (gateway/run.py):**
- Fix sentinel eviction race: _AGENT_PENDING_SENTINEL was immediately
evicted by the stale-detection logic because sentinels have no
get_activity_summary() method, causing _stale_idle=inf >= timeout.
Guard _should_evict with 'is not _AGENT_PENDING_SENTINEL'.
* fix: address remaining CI failures
- test_setup_openclaw_migration: Also mock _offer_launch_chat (called at
end of both quick and full setup paths)
- test_code_execution: Move TERMINAL_ENV=local to module level to protect
ALL test classes (TestEnvVarFiltering, TestExecuteCodeEdgeCases,
TestInterruptHandling, TestHeadTailTruncation) from xdist env leaks
- test_matrix: Use try/except for nio.crypto imports (importorskip can be
fooled by MagicMock in sys.modules under xdist)
2026-04-07 09:58:45 -07:00
|
|
|
except ImportError:
|
2026-04-11 06:58:32 +05:30
|
|
|
pytest.skip("mautrix not installed", allow_module_level=True)
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
from gateway.platforms.base import MessageType
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Adapter helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _make_adapter():
|
|
|
|
|
"""Create a MatrixAdapter with mocked config."""
|
|
|
|
|
from gateway.platforms.matrix import MatrixAdapter
|
|
|
|
|
from gateway.config import PlatformConfig
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
config = PlatformConfig(
|
|
|
|
|
enabled=True,
|
|
|
|
|
token="***",
|
|
|
|
|
extra={
|
|
|
|
|
"homeserver": "https://matrix.example.org",
|
|
|
|
|
"user_id": "@bot:example.org",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
adapter = MatrixAdapter(config)
|
|
|
|
|
return adapter
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_audio_event(
|
|
|
|
|
event_id: str = "$audio_event",
|
|
|
|
|
sender: str = "@alice:example.org",
|
2026-04-11 06:58:32 +05:30
|
|
|
room_id: str = "!test:example.org",
|
2026-03-30 00:02:51 -07:00
|
|
|
body: str = "Voice message",
|
|
|
|
|
url: str = "mxc://example.org/abc123",
|
|
|
|
|
is_voice: bool = False,
|
|
|
|
|
mimetype: str = "audio/ogg",
|
2026-04-11 06:58:32 +05:30
|
|
|
timestamp: int = 9999999999000, # ms
|
2026-03-30 00:02:51 -07:00
|
|
|
):
|
|
|
|
|
"""
|
2026-04-11 06:58:32 +05:30
|
|
|
Create a mock mautrix room message event.
|
|
|
|
|
|
|
|
|
|
In mautrix, the handler receives a single event object with attributes
|
|
|
|
|
``room_id``, ``sender``, ``event_id``, ``timestamp``, and ``content``
|
|
|
|
|
(a dict-like or serializable object).
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
Args:
|
2026-04-11 06:58:32 +05:30
|
|
|
is_voice: If True, adds org.matrix.msc3245.voice field to content.
|
2026-03-30 00:02:51 -07:00
|
|
|
"""
|
|
|
|
|
content = {
|
|
|
|
|
"msgtype": "m.audio",
|
|
|
|
|
"body": body,
|
|
|
|
|
"url": url,
|
|
|
|
|
"info": {
|
|
|
|
|
"mimetype": mimetype,
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
if is_voice:
|
|
|
|
|
content["org.matrix.msc3245.voice"] = {}
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
event = SimpleNamespace(
|
|
|
|
|
event_id=event_id,
|
|
|
|
|
sender=sender,
|
|
|
|
|
room_id=room_id,
|
|
|
|
|
timestamp=timestamp,
|
|
|
|
|
content=content,
|
|
|
|
|
)
|
2026-03-30 00:02:51 -07:00
|
|
|
return event
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 06:58:32 +05:30
|
|
|
def _make_state_store(member_count: int = 2):
|
|
|
|
|
"""Create a mock state store with get_members/get_member support."""
|
|
|
|
|
store = MagicMock()
|
|
|
|
|
# get_members returns a list of member user IDs
|
|
|
|
|
members = [MagicMock() for _ in range(member_count)]
|
|
|
|
|
store.get_members = AsyncMock(return_value=members)
|
|
|
|
|
# get_member returns a single member info object
|
|
|
|
|
member = MagicMock()
|
|
|
|
|
member.displayname = "Alice"
|
|
|
|
|
store.get_member = AsyncMock(return_value=member)
|
|
|
|
|
return store
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-04-11 06:58:32 +05:30
|
|
|
# Tests: MSC3245 Voice Detection
|
2026-03-30 00:02:51 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class TestMatrixVoiceMessageDetection:
|
|
|
|
|
"""Test that MSC3245 voice messages are detected and tagged correctly."""
|
|
|
|
|
|
|
|
|
|
def setup_method(self):
|
|
|
|
|
self.adapter = _make_adapter()
|
|
|
|
|
self.adapter._user_id = "@bot:example.org"
|
|
|
|
|
self.adapter._startup_ts = 0.0
|
|
|
|
|
self.adapter._dm_rooms = {}
|
|
|
|
|
self.adapter._message_handler = AsyncMock()
|
|
|
|
|
# Mock _mxc_to_http to return a fake HTTP URL
|
|
|
|
|
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
2026-04-11 06:58:32 +05:30
|
|
|
# Mock client for authenticated download — download_media returns bytes directly
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter._client = MagicMock()
|
2026-04-11 06:58:32 +05:30
|
|
|
self.adapter._client.download_media = AsyncMock(return_value=b"fake audio data")
|
|
|
|
|
# State store for DM detection
|
|
|
|
|
self.adapter._client.state_store = _make_state_store()
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_voice_message_has_type_voice(self):
|
|
|
|
|
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
|
|
|
|
event = _make_audio_event(is_voice=True)
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
# Capture the MessageEvent passed to handle_message
|
|
|
|
|
captured_event = None
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
async def capture(msg_event):
|
|
|
|
|
nonlocal captured_event
|
|
|
|
|
captured_event = msg_event
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter.handle_message = capture
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
await self.adapter._on_room_message(event)
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event is not None, "No event was captured"
|
|
|
|
|
assert captured_event.message_type == MessageType.VOICE, \
|
|
|
|
|
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_voice_message_has_local_path(self):
|
|
|
|
|
"""Voice messages should have a local cached path in media_urls."""
|
|
|
|
|
event = _make_audio_event(is_voice=True)
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
captured_event = None
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
async def capture(msg_event):
|
|
|
|
|
nonlocal captured_event
|
|
|
|
|
captured_event = msg_event
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter.handle_message = capture
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
await self.adapter._on_room_message(event)
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event is not None
|
|
|
|
|
assert captured_event.media_urls is not None
|
|
|
|
|
assert len(captured_event.media_urls) > 0
|
|
|
|
|
# Should be a local path, not an HTTP URL
|
|
|
|
|
assert not captured_event.media_urls[0].startswith("http"), \
|
|
|
|
|
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
2026-04-11 06:58:32 +05:30
|
|
|
# download_media is called with a ContentURI wrapping the mxc URL
|
|
|
|
|
self.adapter._client.download_media.assert_awaited_once()
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event.media_types == ["audio/ogg"]
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_audio_without_msc3245_stays_audio_type(self):
|
|
|
|
|
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
|
|
|
|
event = _make_audio_event(is_voice=False) # NOT a voice message
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
captured_event = None
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
async def capture(msg_event):
|
|
|
|
|
nonlocal captured_event
|
|
|
|
|
captured_event = msg_event
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter.handle_message = capture
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
await self.adapter._on_room_message(event)
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event is not None
|
|
|
|
|
assert captured_event.message_type == MessageType.AUDIO, \
|
|
|
|
|
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_regular_audio_has_http_url(self):
|
|
|
|
|
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
|
|
|
|
event = _make_audio_event(is_voice=False)
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
captured_event = None
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
async def capture(msg_event):
|
|
|
|
|
nonlocal captured_event
|
|
|
|
|
captured_event = msg_event
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter.handle_message = capture
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
await self.adapter._on_room_message(event)
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event is not None
|
|
|
|
|
assert captured_event.media_urls is not None
|
|
|
|
|
# Should be HTTP URL, not local path
|
|
|
|
|
assert captured_event.media_urls[0].startswith("http"), \
|
|
|
|
|
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
2026-04-11 06:58:32 +05:30
|
|
|
self.adapter._client.download_media.assert_not_awaited()
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event.media_types == ["audio/ogg"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMatrixVoiceCacheFallback:
|
|
|
|
|
"""Test graceful fallback when voice caching fails."""
|
|
|
|
|
|
|
|
|
|
def setup_method(self):
|
|
|
|
|
self.adapter = _make_adapter()
|
|
|
|
|
self.adapter._user_id = "@bot:example.org"
|
|
|
|
|
self.adapter._startup_ts = 0.0
|
|
|
|
|
self.adapter._dm_rooms = {}
|
|
|
|
|
self.adapter._message_handler = AsyncMock()
|
|
|
|
|
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
|
|
|
|
self.adapter._client = MagicMock()
|
2026-04-11 06:58:32 +05:30
|
|
|
self.adapter._client.state_store = _make_state_store()
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
2026-04-11 06:58:32 +05:30
|
|
|
"""If caching fails (download returns None), voice message should still be delivered with HTTP URL."""
|
2026-03-30 00:02:51 -07:00
|
|
|
event = _make_audio_event(is_voice=True)
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
# download_media returns None on failure
|
|
|
|
|
self.adapter._client.download_media = AsyncMock(return_value=None)
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
captured_event = None
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
async def capture(msg_event):
|
|
|
|
|
nonlocal captured_event
|
|
|
|
|
captured_event = msg_event
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter.handle_message = capture
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
await self.adapter._on_room_message(event)
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
assert captured_event is not None
|
|
|
|
|
assert captured_event.media_urls is not None
|
|
|
|
|
# Should fall back to HTTP URL
|
|
|
|
|
assert captured_event.media_urls[0].startswith("http"), \
|
|
|
|
|
f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}"
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
|
|
|
|
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
|
|
|
|
event = _make_audio_event(is_voice=True)
|
|
|
|
|
|
2026-04-11 06:58:32 +05:30
|
|
|
self.adapter._client.download_media = AsyncMock(side_effect=RuntimeError("boom"))
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
captured_event = None
|
|
|
|
|
|
|
|
|
|
async def capture(msg_event):
|
|
|
|
|
nonlocal captured_event
|
|
|
|
|
captured_event = msg_event
|
|
|
|
|
|
|
|
|
|
self.adapter.handle_message = capture
|
|
|
|
|
|
2026-04-11 06:58:32 +05:30
|
|
|
await self.adapter._on_room_message(event)
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
assert captured_event is not None
|
|
|
|
|
assert captured_event.media_urls is not None
|
|
|
|
|
assert captured_event.media_urls[0].startswith("http"), \
|
|
|
|
|
f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-04-11 06:58:32 +05:30
|
|
|
# Tests: send_voice includes MSC3245 field
|
2026-03-30 00:02:51 -07:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
class TestMatrixSendVoiceMSC3245:
|
|
|
|
|
"""Test that send_voice includes MSC3245 field for native voice rendering."""
|
|
|
|
|
|
|
|
|
|
def setup_method(self):
|
|
|
|
|
self.adapter = _make_adapter()
|
|
|
|
|
self.adapter._user_id = "@bot:example.org"
|
2026-04-11 06:58:32 +05:30
|
|
|
# Mock client — upload_media returns a ContentURI string
|
2026-03-30 00:02:51 -07:00
|
|
|
self.adapter._client = MagicMock()
|
|
|
|
|
self.upload_call = None
|
|
|
|
|
|
2026-04-11 06:58:32 +05:30
|
|
|
async def mock_upload_media(data, mime_type=None, filename=None, **kwargs):
|
|
|
|
|
self.upload_call = {"data": data, "mime_type": mime_type, "filename": filename}
|
|
|
|
|
return "mxc://example.org/uploaded"
|
2026-03-30 00:02:51 -07:00
|
|
|
|
2026-04-11 06:58:32 +05:30
|
|
|
self.adapter._client.upload_media = mock_upload_media
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-04-11 06:58:32 +05:30
|
|
|
@patch("mimetypes.guess_type", return_value=("audio/ogg", None))
|
|
|
|
|
async def test_send_voice_includes_msc3245_field(self, _mock_guess):
|
2026-03-30 00:02:51 -07:00
|
|
|
"""send_voice should include org.matrix.msc3245.voice in message content."""
|
|
|
|
|
# Create a temp audio file
|
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
|
|
|
|
f.write(b"fake audio data")
|
|
|
|
|
temp_path = f.name
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
try:
|
2026-04-11 06:58:32 +05:30
|
|
|
# Capture the message content sent via send_message_event
|
2026-03-30 00:02:51 -07:00
|
|
|
sent_content = None
|
2026-04-11 06:58:32 +05:30
|
|
|
|
|
|
|
|
async def mock_send_message_event(room_id, event_type, content):
|
2026-03-30 00:02:51 -07:00
|
|
|
nonlocal sent_content
|
|
|
|
|
sent_content = content
|
2026-04-11 06:58:32 +05:30
|
|
|
# send_message_event returns an EventID string
|
|
|
|
|
return "$sent_event"
|
|
|
|
|
|
|
|
|
|
self.adapter._client.send_message_event = mock_send_message_event
|
|
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
await self.adapter.send_voice(
|
|
|
|
|
chat_id="!room:example.org",
|
|
|
|
|
audio_path=temp_path,
|
|
|
|
|
caption="Test voice",
|
|
|
|
|
)
|
2026-04-11 06:58:32 +05:30
|
|
|
|
2026-03-30 00:02:51 -07:00
|
|
|
assert sent_content is not None, "No message was sent"
|
|
|
|
|
assert "org.matrix.msc3245.voice" in sent_content, \
|
|
|
|
|
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
|
|
|
|
assert sent_content["msgtype"] == "m.audio"
|
|
|
|
|
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
2026-04-11 06:58:32 +05:30
|
|
|
assert self.upload_call is not None, "Expected upload_media() to be called"
|
|
|
|
|
assert isinstance(self.upload_call["data"], bytes)
|
|
|
|
|
assert self.upload_call["mime_type"] == "audio/ogg"
|
|
|
|
|
assert self.upload_call["filename"].endswith(".ogg")
|
2026-03-30 00:02:51 -07:00
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
os.unlink(temp_path)
|