"""Tests for the platform adapter registry and dynamic Platform enum.""" import os import pytest from unittest.mock import MagicMock from gateway.platform_registry import PlatformRegistry, PlatformEntry from gateway.config import Platform, GatewayConfig # ── Platform enum dynamic members ───────────────────────────────────────── class TestPlatformEnumDynamic: """Test that Platform enum accepts unknown values for plugin platforms.""" def test_builtin_members_still_work(self): assert Platform.TELEGRAM.value == "telegram" assert Platform("telegram") is Platform.TELEGRAM def test_dynamic_member_case_normalised(self): """Mixed case normalised to lowercase.""" a = Platform("IRC") b = Platform("irc") assert a is b assert a.value == "irc" def test_dynamic_member_with_hyphens(self): """Registered plugin platforms with hyphens work once registered.""" from gateway.platform_registry import platform_registry as _reg entry = PlatformEntry( name="my-platform", label="My Platform", adapter_factory=lambda cfg: MagicMock(), check_fn=lambda: True, source="plugin", ) _reg.register(entry) try: p = Platform("my-platform") assert p.value == "my-platform" assert p.name == "MY_PLATFORM" finally: _reg.unregister("my-platform") # ── PlatformRegistry ────────────────────────────────────────────────────── class TestPlatformRegistry: """Test the PlatformRegistry itself.""" def _make_entry(self, name="test", check_ok=True, validate_ok=True, factory_ok=True): adapter_mock = MagicMock() return PlatformEntry( name=name, label=name.title(), adapter_factory=lambda cfg, _m=adapter_mock: _m if factory_ok else (_ for _ in ()).throw(RuntimeError("factory error")), check_fn=lambda: check_ok, validate_config=lambda cfg: validate_ok, required_env=[], source="plugin", ), adapter_mock def test_register_and_get(self): reg = PlatformRegistry() entry, _ = self._make_entry("alpha") reg.register(entry) assert reg.get("alpha") is entry assert reg.is_registered("alpha") def test_get_unknown_returns_none(self): reg = PlatformRegistry() assert reg.get("nonexistent") is None def test_unregister(self): reg = PlatformRegistry() entry, _ = self._make_entry("beta") reg.register(entry) assert reg.unregister("beta") is True assert reg.get("beta") is None assert reg.unregister("beta") is False # already gone def test_create_adapter_no_validate(self): """When validate_config is None, skip validation.""" reg = PlatformRegistry() mock_adapter = MagicMock() entry = PlatformEntry( name="novalidate", label="NoValidate", adapter_factory=lambda cfg: mock_adapter, check_fn=lambda: True, validate_config=None, source="plugin", ) reg.register(entry) assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter def test_registered_names_includes_deferred_without_materializing(self): reg = PlatformRegistry() entry, _ = self._make_entry("concrete") loader = MagicMock() reg.register(entry) reg.register_deferred("deferred", loader) assert reg.registered_names() == {"concrete", "deferred"} loader.assert_not_called() assert reg.get("concrete") is entry assert reg.is_registered("deferred") class TestEnsureDepsFn: """check_fn (PASSIVE probe) vs ensure_deps_fn (ACTIVE installer) split. Regression for #79812: Teams registered its passive probe as check_fn, so create_adapter() returned None before connect() could lazy-install — the SDK never installed. The inverse wiring (active installer as check_fn) made status displays pip-install SDKs as a side effect. create_adapter() now runs ensure_deps_fn when check_fn is False. """ def _entry(self, name, *, check_fn, ensure_deps_fn=None): adapter = MagicMock() entry = PlatformEntry( name=name, label=name.title(), adapter_factory=lambda cfg: adapter, check_fn=check_fn, ensure_deps_fn=ensure_deps_fn, source="plugin", ) return entry, adapter def test_deps_present_skips_installer(self): """check_fn True → adapter created, ensure_deps_fn never called.""" reg = PlatformRegistry() installer = MagicMock(return_value=True) entry, adapter = self._entry( "ready", check_fn=lambda: True, ensure_deps_fn=installer ) reg.register(entry) assert reg.create_adapter("ready", MagicMock()) is adapter installer.assert_not_called() def test_missing_deps_runs_installer_then_creates(self): """check_fn False + ensure_deps_fn True → install runs, adapter created.""" reg = PlatformRegistry() installer = MagicMock(return_value=True) entry, adapter = self._entry( "installable", check_fn=lambda: False, ensure_deps_fn=installer ) reg.register(entry) assert reg.create_adapter("installable", MagicMock()) is adapter installer.assert_called_once() def test_install_failure_returns_none(self): """check_fn False + ensure_deps_fn False → no adapter.""" reg = PlatformRegistry() installer = MagicMock(return_value=False) entry, _ = self._entry( "broken", check_fn=lambda: False, ensure_deps_fn=installer ) reg.register(entry) assert reg.create_adapter("broken", MagicMock()) is None installer.assert_called_once() def test_no_installer_missing_deps_returns_none(self): """check_fn False + no ensure_deps_fn → hard block (legacy behavior).""" reg = PlatformRegistry() entry, _ = self._entry("blocked", check_fn=lambda: False) reg.register(entry) assert reg.create_adapter("blocked", MagicMock()) is None def test_installer_exception_returns_none(self): """ensure_deps_fn raising is caught, adapter not created.""" reg = PlatformRegistry() def _boom(): raise RuntimeError("pip exploded") entry, _ = self._entry( "explosive", check_fn=lambda: False, ensure_deps_fn=_boom ) reg.register(entry) assert reg.create_adapter("explosive", MagicMock()) is None def test_check_fn_exception_falls_through_to_installer(self): """A raising check_fn is treated as deps-missing, installer still runs.""" reg = PlatformRegistry() def _bad_probe(): raise RuntimeError("probe error") installer = MagicMock(return_value=True) entry, adapter = self._entry( "flaky", check_fn=_bad_probe, ensure_deps_fn=installer ) reg.register(entry) assert reg.create_adapter("flaky", MagicMock()) is adapter installer.assert_called_once() # ── GatewayConfig integration ──────────────────────────────────────────── class TestGatewayConfigPluginPlatform: """Test that GatewayConfig parses and validates plugin platforms.""" def test_get_connected_platforms_includes_registered_plugin(self): """Plugin platform with registry entry passes get_connected_platforms.""" # Register a fake plugin platform from gateway.platform_registry import platform_registry as _reg test_entry = PlatformEntry( name="testplat", label="TestPlat", adapter_factory=lambda cfg: MagicMock(), check_fn=lambda: True, validate_config=lambda cfg: bool(cfg.extra.get("token")), source="plugin", ) _reg.register(test_entry) try: data = { "platforms": { "testplat": {"enabled": True, "extra": {"token": "abc"}}, } } cfg = GatewayConfig.from_dict(data) connected = cfg.get_connected_platforms() connected_values = {p.value for p in connected} assert "testplat" in connected_values finally: _reg.unregister("testplat") # ── Extended PlatformEntry fields ───────────────────────────────────── class TestPlatformEntryExtendedFields: """Test the auth, message length, and display fields on PlatformEntry.""" def test_default_field_values(self): entry = PlatformEntry( name="test", label="Test", adapter_factory=lambda cfg: None, check_fn=lambda: True, ) assert entry.allowed_users_env == "" assert entry.allow_all_env == "" assert entry.max_message_length == 0 assert entry.pii_safe is False assert entry.emoji == "🔌" assert entry.allow_update_command is True # ── Cron platform resolution ───────────────────────────────────────── class TestCronPlatformResolution: """Test that cron delivery accepts plugin platform names.""" def test_builtin_platform_resolves(self): """Built-in platform names resolve via Platform() call.""" p = Platform("telegram") assert p is Platform.TELEGRAM # ── platforms.py integration ────────────────────────────────────────── class TestPlatformsMerge: """Test get_all_platforms() merges with registry.""" def test_get_all_platforms_includes_plugin(self): from hermes_cli.platforms import get_all_platforms from gateway.platform_registry import platform_registry as _reg _reg.register(PlatformEntry( name="testmerge", label="TestMerge", adapter_factory=lambda cfg: None, check_fn=lambda: True, source="plugin", emoji="🧪", )) try: merged = get_all_platforms() assert "testmerge" in merged assert "TestMerge" in merged["testmerge"].label finally: _reg.unregister("testmerge") # ── apply_yaml_config_fn (PlatformEntry field + load_gateway_config dispatch) ── class TestApplyYamlConfigFnField: """The hook field itself — defaults, custom values, signature.""" def test_default_is_none(self): entry = PlatformEntry( name="test", label="Test", adapter_factory=lambda cfg: None, check_fn=lambda: True, ) assert entry.apply_yaml_config_fn is None class TestApplyYamlConfigFnDispatch: """End-to-end dispatch through load_gateway_config(). Each test registers a temporary PlatformEntry, writes a config.yaml in a tmp HERMES_HOME, calls load_gateway_config(), and asserts the hook was invoked correctly. Cleanup unregisters the entry. """ def _write_config(self, tmp_path, content: str): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text(content, encoding="utf-8") return hermes_home def _register_hook(self, name, hook_fn): from gateway.platform_registry import platform_registry as _reg entry = PlatformEntry( name=name, label=name.title(), adapter_factory=lambda cfg: None, check_fn=lambda: True, source="plugin", apply_yaml_config_fn=hook_fn, ) _reg.register(entry) return _reg def test_hook_exception_swallowed(self, tmp_path, monkeypatch): """A misbehaving hook never aborts load_gateway_config().""" def _bad_hook(yaml_cfg, platform_cfg): raise RuntimeError("plugin author bug") # Also register a well-behaved hook to ensure dispatch continues # iterating after a bad one. good_called = {"count": 0} def _good_hook(yaml_cfg, platform_cfg): good_called["count"] += 1 return None from gateway.platform_registry import platform_registry as _reg _reg.register(PlatformEntry( name="mybadplat", label="MyBad", adapter_factory=lambda cfg: None, check_fn=lambda: True, source="plugin", apply_yaml_config_fn=_bad_hook, )) _reg.register(PlatformEntry( name="mygoodplat", label="MyGood", adapter_factory=lambda cfg: None, check_fn=lambda: True, source="plugin", apply_yaml_config_fn=_good_hook, )) try: home = self._write_config( tmp_path, "mybadplat:\n k: v\n" "mygoodplat:\n k: v\n", ) monkeypatch.setenv("HERMES_HOME", str(home)) # Must not raise. from gateway.config import load_gateway_config load_gateway_config() assert good_called["count"] == 1 finally: _reg.unregister("mybadplat") _reg.unregister("mygoodplat") def test_env_var_takes_precedence_when_hook_uses_getenv_guard( self, tmp_path, monkeypatch ): """The standard `not os.getenv(...)` guard preserves env > YAML.""" env_var = "MYPRECPLAT_FLAG" monkeypatch.setenv(env_var, "preexisting") def _hook(yaml_cfg, platform_cfg): if "flag" in platform_cfg and not os.getenv(env_var): os.environ[env_var] = str(platform_cfg["flag"]).lower() return None reg = self._register_hook("myprecplat", _hook) try: home = self._write_config( tmp_path, "myprecplat:\n flag: yaml-value\n", ) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config load_gateway_config() # Pre-existing env var was NOT clobbered by the hook. assert os.environ.get(env_var) == "preexisting" finally: reg.unregister("myprecplat") os.environ.pop(env_var, None) class TestPluginPlatformSharedKeyBridge: """Plugin-registered platforms get the same shared-key bridging as built-ins. Without this, plugin authors using ``apply_yaml_config_fn`` would have to re-implement bridging for every common key (``unauthorized_dm_behavior``, ``notice_delivery``, ``reply_prefix``, ``require_mention``, ``dm_policy``, ``allow_from``, etc.) — defeating the hook's whole point of letting plugins focus on their *platform-specific* keys. """ def _write_config(self, tmp_path, content: str): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text(content, encoding="utf-8") return hermes_home def test_shared_keys_bridged_for_plugin_platform(self, tmp_path, monkeypatch): """A plugin platform's ``require_mention``/``dm_policy``/etc. flow into ``PlatformConfig.extra`` without the plugin needing its own bridge.""" from gateway.platform_registry import platform_registry as _reg _reg.register(PlatformEntry( name="mysharedplat", label="MySharedPlat", adapter_factory=lambda cfg: None, check_fn=lambda: True, source="plugin", )) try: home = self._write_config( tmp_path, "mysharedplat:\n" " require_mention: true\n" " dm_policy: allow\n" " reply_prefix: \"→ \"\n" " allow_from: [\"alice\", \"bob\"]\n", ) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config, Platform cfg = load_gateway_config() plat = Platform("mysharedplat") assert plat in cfg.platforms extra = cfg.platforms[plat].extra assert extra.get("require_mention") is True assert extra.get("dm_policy") == "allow" assert extra.get("reply_prefix") == "→ " assert extra.get("allow_from") == ["alice", "bob"] finally: _reg.unregister("mysharedplat") class TestPluginEnablementGate: """Plugin platforms must NOT auto-enable on check_fn alone (#31116). When a plugin registers ``is_connected`` (the "did the user actually configure credentials" probe), ``load_gateway_config`` must consult it before flipping ``enabled = True``. Without this gate, ``check_fn`` semantics ("the SDK is importable") get conflated with "the user wants this platform on", and the gateway tries to connect to e.g. Discord with no token — emitting noisy retry-forever errors on every fresh install that has the plugin loaded. """ def _write_config(self, tmp_path, content: str = ""): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text(content, encoding="utf-8") return hermes_home def test_plugin_with_is_connected_false_is_NOT_enabled( self, tmp_path, monkeypatch ): """check_fn=True + is_connected=False must NOT enable the platform. Reproduces #31116: Discord plugin loads, its check_fn lazy-installs discord.py and returns True, but the user has no DISCORD_BOT_TOKEN. Previously this auto-enabled Discord and the gateway spammed ``ERROR ... [Discord] No bot token configured`` on every reconnect. """ from gateway.platform_registry import platform_registry as _reg _reg.register(PlatformEntry( name="myunconfiguredplat", label="MyUnconfigured", adapter_factory=lambda cfg: None, check_fn=lambda: True, # SDK available is_connected=lambda cfg: False, # but user hasn't set credentials source="plugin", )) try: home = self._write_config(tmp_path) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config, Platform cfg = load_gateway_config() plat = Platform("myunconfiguredplat") # Either absent entirely, or present but explicitly disabled. if plat in cfg.platforms: assert cfg.platforms[plat].enabled is False, ( "Plugin with is_connected=False must NOT be auto-enabled" ) finally: _reg.unregister("myunconfiguredplat") def test_is_connected_raises_does_not_enable(self, tmp_path, monkeypatch): """A buggy is_connected must not silently enable the platform. Treat a raising is_connected as "configuration unknown" — refuse to enable, log, and move on. Anything else would re-introduce the #31116 bug for plugins whose probe has a transient failure. """ from gateway.platform_registry import platform_registry as _reg def _bad_probe(cfg): raise RuntimeError("plugin bug") _reg.register(PlatformEntry( name="mybadprobeplat", label="MyBadProbe", adapter_factory=lambda cfg: None, check_fn=lambda: True, is_connected=_bad_probe, source="plugin", )) try: home = self._write_config(tmp_path) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config, Platform cfg = load_gateway_config() plat = Platform("mybadprobeplat") if plat in cfg.platforms: assert cfg.platforms[plat].enabled is False finally: _reg.unregister("mybadprobeplat") def test_is_connected_failed_gate_does_not_leak_extras( self, tmp_path, monkeypatch ): """When the gate rejects, env-seeded extras must NOT leak onto ``config.platforms``. A rejected plugin should be invisible, not present-but-partially-populated. """ from gateway.platform_registry import platform_registry as _reg _reg.register(PlatformEntry( name="myrejectedplat", label="MyRejected", adapter_factory=lambda cfg: None, check_fn=lambda: True, is_connected=lambda cfg: False, env_enablement_fn=lambda: {"some_key": "should-not-leak"}, source="plugin", )) try: home = self._write_config(tmp_path) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config, Platform cfg = load_gateway_config() plat = Platform("myrejectedplat") if plat in cfg.platforms: assert cfg.platforms[plat].enabled is False assert "some_key" not in cfg.platforms[plat].extra, ( "Rejected plugin's env-seeded extras leaked onto " "config.platforms" ) finally: _reg.unregister("myrejectedplat") def test_missing_deps_with_installer_still_enables( self, tmp_path, monkeypatch ): """is_connected=True + check_fn=False + ensure_deps_fn set → ENABLED. The install is deferred to ``create_adapter()`` at gateway start (#79812). Skipping enablement here would mean a configured platform whose SDK isn't installed yet never gets the chance to install it. """ from gateway.platform_registry import platform_registry as _reg installer = MagicMock(return_value=True) _reg.register(PlatformEntry( name="myinstallableplat", label="MyInstallable", adapter_factory=lambda cfg: None, check_fn=lambda: False, # SDK not installed yet ensure_deps_fn=installer, # ...but installable on demand is_connected=lambda cfg: True, # user configured credentials source="plugin", )) try: home = self._write_config(tmp_path) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config, Platform cfg = load_gateway_config() plat = Platform("myinstallableplat") assert plat in cfg.platforms and cfg.platforms[plat].enabled, ( "Configured platform with a registered installer must be " "enabled; the install runs at create_adapter() time" ) # Config loading must NOT have run the installer (that's the # desktop boot-loop bug — see module docstring of # test_startup_no_eager_platform_install.py). installer.assert_not_called() finally: _reg.unregister("myinstallableplat") def test_missing_deps_without_installer_not_enabled( self, tmp_path, monkeypatch ): """is_connected=True + check_fn=False + NO ensure_deps_fn → skipped. Without an installer, missing deps are a hard block — enabling the platform would just queue guaranteed connect failures. """ from gateway.platform_registry import platform_registry as _reg _reg.register(PlatformEntry( name="myhardblockplat", label="MyHardBlock", adapter_factory=lambda cfg: None, check_fn=lambda: False, is_connected=lambda cfg: True, source="plugin", )) try: home = self._write_config(tmp_path) monkeypatch.setenv("HERMES_HOME", str(home)) from gateway.config import load_gateway_config, Platform cfg = load_gateway_config() plat = Platform("myhardblockplat") if plat in cfg.platforms: assert cfg.platforms[plat].enabled is False finally: _reg.unregister("myhardblockplat") class TestMigratedPlatformWiring: """Every lazy-installable bundled platform must register the split: a PASSIVE check_fn plus an ACTIVE ensure_deps_fn (#79812). Behavior contract, not a snapshot: asserts the two fields are distinct callables (probe != installer), not specific function identities, so renames don't churn this test. """ import pytest as _pytest @_pytest.mark.parametrize( "platform_name", [ "teams", "telegram", "discord", "slack", "matrix", "dingtalk", "feishu", "wecom_callback", ], ) def test_lazy_installable_platform_has_split_wiring(self, platform_name): from hermes_cli.plugins import discover_plugins discover_plugins() from gateway.platform_registry import platform_registry # Materialize deferred loaders (wecom_callback is registered by the # "wecom" manifest's loader; a cold get() by its own name misses). platform_registry.plugin_entries() entry = platform_registry.get(platform_name) assert entry is not None, f"{platform_name} not registered" assert entry.ensure_deps_fn is not None, ( f"{platform_name} has a lazy-installable SDK but no " "ensure_deps_fn — its deps can never auto-install " "(the #79812 deadlock)" ) assert entry.ensure_deps_fn is not entry.check_fn, ( f"{platform_name} registered the same callable for the passive " "probe and the active installer — status displays would " "pip-install as a side effect" )