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