test_voice_mode_platform_isolation.py
1 """Tests for voice mode platform isolation (bug #12542). 2 3 Voice mode state stored as {chat_id: mode} without a platform namespace 4 caused collisions: Telegram chat '123' and Slack chat '123' shared the 5 same key. The fix prefixes keys with platform value: 'telegram:123' vs 6 'slack:123'. 7 """ 8 9 import json 10 import tempfile 11 from pathlib import Path 12 from unittest.mock import MagicMock, patch 13 14 import pytest 15 16 from gateway.config import Platform 17 from gateway.run import GatewayRunner 18 19 20 class TestVoiceKeyHelper: 21 """Test the _voice_key helper method.""" 22 23 def test_voice_key_format(self): 24 """_voice_key returns 'platform:chat_id' format.""" 25 runner = _make_runner() 26 assert runner._voice_key(Platform.TELEGRAM, "123") == "telegram:123" 27 assert runner._voice_key(Platform.SLACK, "456") == "slack:456" 28 assert runner._voice_key(Platform.DISCORD, "789") == "discord:789" 29 30 def test_voice_key_different_platforms_same_chat_id(self): 31 """Same chat_id on different platforms yields different keys.""" 32 runner = _make_runner() 33 key_telegram = runner._voice_key(Platform.TELEGRAM, "123") 34 key_slack = runner._voice_key(Platform.SLACK, "123") 35 key_discord = runner._voice_key(Platform.DISCORD, "123") 36 assert key_telegram != key_slack 37 assert key_slack != key_discord 38 assert key_telegram == "telegram:123" 39 assert key_slack == "slack:123" 40 assert key_discord == "discord:123" 41 42 43 class TestVoiceModePlatformIsolation: 44 """Test that voice mode state is isolated by platform.""" 45 46 def test_telegram_and_slack_voice_mode_independent(self): 47 """Setting voice mode for Telegram chat '123' does not affect Slack chat '123'.""" 48 runner = _make_runner() 49 50 # Enable voice mode for Telegram chat '123' 51 runner._voice_mode[runner._voice_key(Platform.TELEGRAM, "123")] = "all" 52 # Enable voice mode for Slack chat '123' to a different mode 53 runner._voice_mode[runner._voice_key(Platform.SLACK, "123")] = "voice_only" 54 55 # Verify they are independent 56 assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, "123")) == "all" 57 assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, "123")) == "voice_only" 58 59 # Disabling Telegram should not affect Slack 60 runner._voice_mode[runner._voice_key(Platform.TELEGRAM, "123")] = "off" 61 assert runner._voice_mode.get(runner._voice_key(Platform.TELEGRAM, "123")) == "off" 62 assert runner._voice_mode.get(runner._voice_key(Platform.SLACK, "123")) == "voice_only" 63 64 65 class TestLegacyKeyMigration: 66 """Test migration of legacy unprefixed keys in _load_voice_modes.""" 67 68 def test_load_voice_modes_skips_legacy_keys(self): 69 """_load_voice_modes skips keys without ':' prefix and logs a warning.""" 70 runner = _make_runner() 71 72 # Simulate legacy persisted data with unprefixed keys 73 legacy_data = { 74 "123": "all", 75 "456": "voice_only", 76 # Also includes a properly prefixed key (from after the fix) 77 "telegram:789": "off", 78 } 79 80 with tempfile.TemporaryDirectory() as tmpdir: 81 voice_path = Path(tmpdir) / "gateway_voice_mode.json" 82 voice_path.write_text(json.dumps(legacy_data)) 83 84 with patch.object(runner, "_VOICE_MODE_PATH", voice_path): 85 with patch("gateway.run.logger") as mock_logger: 86 result = runner._load_voice_modes() 87 88 # Legacy keys without ':' should be skipped 89 assert "123" not in result 90 assert "456" not in result 91 # Prefixed key should be preserved 92 assert result.get("telegram:789") == "off" 93 # Warning should be logged for each legacy key 94 assert mock_logger.warning.called 95 warning_calls = [str(call) for call in mock_logger.warning.call_args_list] 96 assert any("Skipping legacy unprefixed voice mode key" in str(c) for c in warning_calls) 97 98 def test_load_voice_modes_preserves_prefixed_keys(self): 99 """_load_voice_modes correctly loads platform-prefixed keys.""" 100 runner = _make_runner() 101 102 persisted_data = { 103 "telegram:123": "all", 104 "slack:456": "voice_only", 105 "discord:789": "off", 106 } 107 108 with tempfile.TemporaryDirectory() as tmpdir: 109 voice_path = Path(tmpdir) / "gateway_voice_mode.json" 110 voice_path.write_text(json.dumps(persisted_data)) 111 112 with patch.object(runner, "_VOICE_MODE_PATH", voice_path): 113 result = runner._load_voice_modes() 114 115 assert result.get("telegram:123") == "all" 116 assert result.get("slack:456") == "voice_only" 117 assert result.get("discord:789") == "off" 118 119 def test_load_voice_modes_invalid_modes_filtered(self): 120 """_load_voice_modes filters out invalid mode values.""" 121 runner = _make_runner() 122 123 data = { 124 "telegram:123": "all", 125 "telegram:456": "invalid_mode", 126 "telegram:789": "voice_only", 127 } 128 129 with tempfile.TemporaryDirectory() as tmpdir: 130 voice_path = Path(tmpdir) / "gateway_voice_mode.json" 131 voice_path.write_text(json.dumps(data)) 132 133 with patch.object(runner, "_VOICE_MODE_PATH", voice_path): 134 result = runner._load_voice_modes() 135 136 assert result.get("telegram:123") == "all" 137 assert "telegram:456" not in result 138 assert result.get("telegram:789") == "voice_only" 139 140 141 class TestSyncVoiceModeStateToAdapter: 142 """Test _sync_voice_mode_state_to_adapter filters by platform.""" 143 144 def test_sync_only_includes_platform_chats(self): 145 """Only chats matching the adapter's platform are synced.""" 146 runner = _make_runner() 147 148 # Set up voice mode state with multiple platforms 149 runner._voice_mode = { 150 "telegram:123": "off", # Should sync 151 "telegram:456": "all", # Should NOT sync (mode is not "off") 152 "slack:123": "off", # Should NOT sync (different platform) 153 "discord:789": "off", # Should NOT sync (different platform) 154 } 155 156 # Create a mock Telegram adapter 157 mock_adapter = MagicMock() 158 mock_adapter.platform = Platform.TELEGRAM 159 mock_adapter._auto_tts_disabled_chats = set() 160 161 runner._sync_voice_mode_state_to_adapter(mock_adapter) 162 163 # Only telegram:123 should be in disabled_chats (mode="off" for telegram) 164 assert mock_adapter._auto_tts_disabled_chats == {"123"} 165 166 def test_sync_clears_existing_state(self): 167 """_sync_voice_mode_state_to_adapter clears existing disabled_chats first.""" 168 runner = _make_runner() 169 170 runner._voice_mode = { 171 "telegram:123": "off", 172 } 173 174 mock_adapter = MagicMock() 175 mock_adapter.platform = Platform.TELEGRAM 176 mock_adapter._auto_tts_disabled_chats = {"old_chat_id", "another_old"} 177 178 runner._sync_voice_mode_state_to_adapter(mock_adapter) 179 180 # Old entries should be cleared 181 assert mock_adapter._auto_tts_disabled_chats == {"123"} 182 183 def test_sync_returns_early_without_platform(self): 184 """_sync_voice_mode_state_to_adapter returns early if adapter has no platform.""" 185 runner = _make_runner() 186 runner._voice_mode = {"telegram:123": "off"} 187 188 mock_adapter = MagicMock() 189 mock_adapter.platform = None 190 mock_adapter._auto_tts_disabled_chats = {"old"} 191 192 runner._sync_voice_mode_state_to_adapter(mock_adapter) 193 194 # disabled_chats should not be modified 195 assert mock_adapter._auto_tts_disabled_chats == {"old"} 196 197 def test_sync_returns_early_without_auto_tts_disabled_chats(self): 198 """_sync_voice_mode_state_to_adapter returns early if adapter lacks _auto_tts_disabled_chats.""" 199 runner = _make_runner() 200 runner._voice_mode = {"telegram:123": "off"} 201 202 mock_adapter = MagicMock(spec=[]) # No _auto_tts_disabled_chats attribute 203 204 # Should not raise 205 runner._sync_voice_mode_state_to_adapter(mock_adapter) 206 207 208 # --------------------------------------------------------------------------- 209 # Helper 210 # --------------------------------------------------------------------------- 211 212 def _make_runner() -> GatewayRunner: 213 """Create a minimal GatewayRunner for testing.""" 214 with patch("gateway.run.GatewayRunner._load_voice_modes", return_value={}): 215 runner = GatewayRunner.__new__(GatewayRunner) 216 runner._voice_mode = {} 217 runner.adapters = {} 218 return runner