test_plugin_platform_interface.py
1 """ 2 Interface compliance tests for all plugin-based gateway platforms. 3 4 Discovers platforms dynamically under ``plugins/platforms/`` — no manual 5 enumeration — and verifies each one implements the required contract. 6 """ 7 8 import importlib 9 import sys 10 from pathlib import Path 11 from types import ModuleType 12 from typing import Any 13 from unittest.mock import MagicMock 14 15 import pytest 16 17 PROJECT_ROOT = Path(__file__).parent.parent.resolve() 18 PLATFORMS_DIR = PROJECT_ROOT / "plugins" / "platforms" 19 20 21 def _discover_platform_plugins() -> list[str]: 22 """Return names of all bundled platform plugins.""" 23 if not PLATFORMS_DIR.is_dir(): 24 return [] 25 names = [] 26 for child in sorted(PLATFORMS_DIR.iterdir()): 27 if child.is_dir() and (child / "__init__.py").exists(): 28 names.append(child.name) 29 return names 30 31 32 # Dynamically parametrise over discovered platforms 33 _PLATFORM_NAMES = _discover_platform_plugins() 34 35 36 @pytest.fixture 37 def clean_registry(): 38 """Yield with a clean platform registry, restoring state afterwards.""" 39 from gateway.platform_registry import platform_registry 40 41 original = dict(platform_registry._entries) 42 platform_registry._entries.clear() 43 yield platform_registry 44 platform_registry._entries.clear() 45 platform_registry._entries.update(original) 46 47 48 class _MockPluginContext: 49 """Minimal mock of hermes_cli.plugins.PluginContext. 50 51 Only implements register_platform so we can exercise the plugin's 52 register() entrypoint without importing the real plugin system. 53 """ 54 55 def __init__(self): 56 self.registered_names: list[str] = [] 57 58 def register_platform( 59 self, 60 *, 61 name: str, 62 label: str, 63 adapter_factory: Any, 64 check_fn: Any, 65 **kwargs: Any, 66 ) -> None: 67 from gateway.platform_registry import platform_registry, PlatformEntry 68 69 entry = PlatformEntry( 70 name=name, 71 label=label, 72 adapter_factory=adapter_factory, 73 check_fn=check_fn, 74 **kwargs, 75 ) 76 platform_registry.register(entry) 77 self.registered_names.append(name) 78 79 80 def _import_platform_module(name: str) -> ModuleType: 81 """Import plugins.platforms.<name> in a test-safe way.""" 82 # Make sure the project root is on sys.path so relative imports work 83 if str(PROJECT_ROOT) not in sys.path: 84 sys.path.insert(0, str(PROJECT_ROOT)) 85 module = importlib.import_module(f"plugins.platforms.{name}") 86 return module 87 88 89 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 90 def test_plugin_exposes_register_function(platform_name: str): 91 """Every platform plugin must expose a callable register function.""" 92 module = _import_platform_module(platform_name) 93 assert hasattr(module, "register"), f"{platform_name} missing register()" 94 assert callable(module.register), f"{platform_name}.register not callable" 95 96 97 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 98 def test_plugin_registers_valid_platform_entry(platform_name: str, clean_registry): 99 """Calling register() must create a valid PlatformEntry.""" 100 module = _import_platform_module(platform_name) 101 ctx = _MockPluginContext() 102 module.register(ctx) 103 104 assert platform_name in ctx.registered_names 105 106 from gateway.platform_registry import platform_registry 107 entry = platform_registry.get(platform_name) 108 assert entry is not None, f"{platform_name} did not register an entry" 109 assert entry.name == platform_name 110 assert entry.label 111 assert callable(entry.adapter_factory) 112 assert callable(entry.check_fn) 113 114 115 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 116 def test_platform_entry_has_required_fields(platform_name: str, clean_registry): 117 """PlatformEntry must have the mandatory metadata fields.""" 118 module = _import_platform_module(platform_name) 119 ctx = _MockPluginContext() 120 module.register(ctx) 121 122 from gateway.platform_registry import platform_registry 123 entry = platform_registry.get(platform_name) 124 assert entry is not None 125 126 # Mandatory fields 127 assert isinstance(entry.name, str) and entry.name 128 assert isinstance(entry.label, str) and entry.label 129 assert callable(entry.adapter_factory) 130 assert callable(entry.check_fn) 131 132 # Optional but recommended fields 133 if entry.validate_config is not None: 134 assert callable(entry.validate_config) 135 if entry.is_connected is not None: 136 assert callable(entry.is_connected) 137 if entry.setup_fn is not None: 138 assert callable(entry.setup_fn) 139 140 141 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 142 def test_adapter_factory_produces_valid_adapter(platform_name: str, clean_registry): 143 """The adapter factory must return an object with the base interface.""" 144 module = _import_platform_module(platform_name) 145 ctx = _MockPluginContext() 146 module.register(ctx) 147 148 from gateway.platform_registry import platform_registry 149 entry = platform_registry.get(platform_name) 150 assert entry is not None 151 152 # Build a minimal synthetic config that shouldn't crash __init__ 153 mock_config = MagicMock() 154 mock_config.extra = {} 155 mock_config.enabled = True 156 mock_config.token = None 157 mock_config.api_key = None 158 mock_config.home_channel = None 159 mock_config.reply_to_mode = "first" 160 161 adapter = entry.adapter_factory(mock_config) 162 assert adapter is not None, f"{platform_name} adapter_factory returned None" 163 164 # Required adapter interface 165 assert hasattr(adapter, "connect") and callable(adapter.connect) 166 assert hasattr(adapter, "disconnect") and callable(adapter.disconnect) 167 assert hasattr(adapter, "send") and callable(adapter.send) 168 assert hasattr(adapter, "name") 169 170 # Should be a BasePlatformAdapter subclass if importable 171 try: 172 from gateway.platforms.base import BasePlatformAdapter 173 assert isinstance(adapter, BasePlatformAdapter) 174 except Exception: 175 pytest.skip("BasePlatformAdapter not available for isinstance check") 176 177 178 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 179 def test_check_fn_returns_bool(platform_name: str, clean_registry): 180 """check_fn() must return a boolean.""" 181 module = _import_platform_module(platform_name) 182 ctx = _MockPluginContext() 183 module.register(ctx) 184 185 from gateway.platform_registry import platform_registry 186 entry = platform_registry.get(platform_name) 187 assert entry is not None 188 189 result = entry.check_fn() 190 assert isinstance(result, bool), f"{platform_name}.check_fn() returned {type(result)}, expected bool" 191 192 193 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 194 def test_validate_config_if_present(platform_name: str, clean_registry): 195 """If validate_config is provided, it must accept a config object.""" 196 module = _import_platform_module(platform_name) 197 ctx = _MockPluginContext() 198 module.register(ctx) 199 200 from gateway.platform_registry import platform_registry 201 entry = platform_registry.get(platform_name) 202 assert entry is not None 203 204 if entry.validate_config is None: 205 pytest.skip("No validate_config provided") 206 207 mock_config = MagicMock() 208 mock_config.extra = {} 209 result = entry.validate_config(mock_config) 210 assert isinstance(result, bool) 211 212 213 @pytest.mark.parametrize("platform_name", _PLATFORM_NAMES) 214 def test_is_connected_if_present(platform_name: str, clean_registry): 215 """If is_connected is provided, it must accept a config object.""" 216 module = _import_platform_module(platform_name) 217 ctx = _MockPluginContext() 218 module.register(ctx) 219 220 from gateway.platform_registry import platform_registry 221 entry = platform_registry.get(platform_name) 222 assert entry is not None 223 224 if entry.is_connected is None: 225 pytest.skip("No is_connected provided") 226 227 mock_config = MagicMock() 228 mock_config.extra = {} 229 result = entry.is_connected(mock_config) 230 assert isinstance(result, bool)