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