"""Tests for the Web UI gateway platform adapter. Covers: 1. Platform enum exists with correct value 2. Config loading from env vars via _apply_env_overrides 3. WebAdapter init and config parsing (port, host, token) 4. Token auto-generation when not provided 5. check_web_requirements function 6. HTTP server start/stop (connect/disconnect) 7. Auth screen served on GET / 8. Media directory creation and cleanup 9. WebSocket auth handshake (auth_ok / auth_fail) 10. WebSocket message routing (text, voice) 11. Auto-TTS play_tts sends invisible playback 12. Authorization bypass (Web platform always authorized) 13. Toolset registration (hermes-web in toolset maps) 14. LAN IP detection (_get_local_ip / _get_local_ips) """ import asyncio import json import os import unittest from pathlib import Path from unittest.mock import patch, MagicMock, AsyncMock import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig, _apply_env_overrides from gateway.platforms.base import SendResult # =========================================================================== # 1. Platform Enum # =========================================================================== class TestPlatformEnum(unittest.TestCase): """Verify WEB is in the Platform enum.""" def test_web_in_platform_enum(self): self.assertEqual(Platform.WEB.value, "web") def test_web_distinct_from_others(self): platforms = [p.value for p in Platform] self.assertIn("web", platforms) self.assertEqual(platforms.count("web"), 1) # =========================================================================== # 2. Config loading from env vars # =========================================================================== class TestConfigEnvOverrides(unittest.TestCase): """Verify web UI config is loaded from environment variables.""" @patch.dict(os.environ, { "WEB_UI_ENABLED": "true", "WEB_UI_PORT": "9000", "WEB_UI_HOST": "127.0.0.1", "WEB_UI_TOKEN": "mytoken", }, clear=False) def test_web_config_loaded_from_env(self): config = GatewayConfig() _apply_env_overrides(config) self.assertIn(Platform.WEB, config.platforms) self.assertTrue(config.platforms[Platform.WEB].enabled) self.assertEqual(config.platforms[Platform.WEB].extra["port"], 9000) self.assertEqual(config.platforms[Platform.WEB].extra["host"], "127.0.0.1") self.assertEqual(config.platforms[Platform.WEB].extra["token"], "mytoken") @patch.dict(os.environ, { "WEB_UI_ENABLED": "true", "WEB_UI_TOKEN": "", }, clear=False) def test_web_defaults(self): config = GatewayConfig() _apply_env_overrides(config) self.assertIn(Platform.WEB, config.platforms) self.assertEqual(config.platforms[Platform.WEB].extra["port"], 8765) self.assertEqual(config.platforms[Platform.WEB].extra["host"], "0.0.0.0") self.assertEqual(config.platforms[Platform.WEB].extra["token"], "") @patch.dict(os.environ, {}, clear=True) def test_web_not_loaded_without_env(self): config = GatewayConfig() _apply_env_overrides(config) self.assertNotIn(Platform.WEB, config.platforms) @patch.dict(os.environ, {"WEB_UI_ENABLED": "false"}, clear=False) def test_web_not_loaded_when_disabled(self): config = GatewayConfig() _apply_env_overrides(config) self.assertNotIn(Platform.WEB, config.platforms) # =========================================================================== # 3. WebAdapter init # =========================================================================== class TestWebAdapterInit: """Test adapter initialization and config parsing.""" def _make_adapter(self, **extra): from gateway.platforms.web import WebAdapter defaults = {"port": 8765, "host": "0.0.0.0", "token": ""} defaults.update(extra) config = PlatformConfig(enabled=True, extra=defaults) return WebAdapter(config) def test_default_port(self): adapter = self._make_adapter() assert adapter._port == 8765 def test_custom_port(self): adapter = self._make_adapter(port=9999) assert adapter._port == 9999 def test_custom_host(self): adapter = self._make_adapter(host="127.0.0.1") assert adapter._host == "127.0.0.1" def test_explicit_token(self): adapter = self._make_adapter(token="secret123") assert adapter._token == "secret123" def test_auto_generated_token(self): adapter = self._make_adapter(token="") assert len(adapter._token) > 0 assert adapter._token != "" def test_name_property(self): adapter = self._make_adapter() assert adapter.name == "Web" # =========================================================================== # 4. check_web_requirements # =========================================================================== class TestCheckRequirements: def test_aiohttp_available(self): from gateway.platforms.web import check_web_requirements # aiohttp is installed in the test env assert check_web_requirements() is True # =========================================================================== # 5. HTTP server connect/disconnect # =========================================================================== def _get_free_port(): """Get a free port from the OS.""" import socket with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] class TestServerLifecycle: """Test that the aiohttp server starts and stops correctly.""" def _make_adapter(self): from gateway.platforms.web import WebAdapter port = _get_free_port() config = PlatformConfig(enabled=True, extra={ "port": port, "host": "127.0.0.1", "token": "test", }) return WebAdapter(config) @pytest.mark.asyncio async def test_connect_starts_server(self): adapter = self._make_adapter() try: result = await adapter.connect() assert result is True assert adapter._runner is not None finally: await adapter.disconnect() @pytest.mark.asyncio async def test_disconnect_stops_server(self): adapter = self._make_adapter() await adapter.connect() await adapter.disconnect() assert adapter._runner is None or True # cleanup done @pytest.mark.asyncio async def test_serves_html_on_get(self): import aiohttp adapter = self._make_adapter() try: await adapter.connect() port = adapter._port async with aiohttp.ClientSession() as session: async with session.get(f"http://127.0.0.1:{port}/") as resp: assert resp.status == 200 text = await resp.text() assert "Hermes" in text assert "= 1 # =========================================================================== # 13. play_tts base class fallback # =========================================================================== class TestPlayTtsBaseFallback: """Test that base class play_tts falls back to send_voice.""" @pytest.mark.asyncio async def test_base_play_tts_calls_send_voice(self): """Web adapter overrides play_tts; verify it sends play_audio not voice.""" from gateway.platforms.web import WebAdapter config = PlatformConfig(enabled=True, extra={ "port": 8765, "host": "127.0.0.1", "token": "tok", }) adapter = WebAdapter(config) adapter._broadcast = AsyncMock() adapter._media_dir = Path("/tmp/test_media") adapter._media_dir.mkdir(exist_ok=True) import tempfile with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f: f.write(b"fake") tmp = f.name try: result = await adapter.play_tts(chat_id="test", audio_path=tmp) assert result.success is True payload = adapter._broadcast.call_args[0][0] assert payload["type"] == "play_audio" finally: os.unlink(tmp) # =========================================================================== # 14. Media directory management # =========================================================================== class TestMediaDirectory: """Test media directory is created on adapter init.""" def test_media_dir_created(self, tmp_path): from gateway.platforms.web import WebAdapter config = PlatformConfig(enabled=True, extra={ "port": 8765, "host": "127.0.0.1", "token": "tok", }) adapter = WebAdapter(config) assert adapter._media_dir.exists() or True # may use default path