from __future__ import annotations import asyncio import pytest from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.channels.contracts import ( ChannelInstanceSpec, ChannelManagementSpec, ChannelSetupSpec, ) from nanobot.channels.manager import ChannelManager from nanobot.channels.plugin import ChannelPlugin from nanobot.config.schema import Config class _HotChannel(BaseChannel): name = "hot" display_name = "Hot" def __init__(self, config, bus): super().__init__(config, bus) self.started = asyncio.Event() self.stopped = asyncio.Event() async def start(self): self._running = True self.started.set() await self.stopped.wait() async def stop(self): self._running = False self.stopped.set() async def send(self, msg): # pragma: no cover - not used by this test raise AssertionError("send should not be called") class _MultiHotChannel(_HotChannel): name = "multi" display_name = "Multi" class _AliasHotChannel(_HotChannel): """Package descriptor alias that claims another channel's runtime namespace.""" name = "hot" display_name = "Alias" def _multi_instance_specs(section, *, enabled_only=True): instances = section.get("instances", []) if isinstance(section, dict) else [] return [ ChannelInstanceSpec( instance_id=item["id"], config=item, ) for item in instances if not enabled_only or item.get("enabled", False) ] def _plugin(channel_cls: type[BaseChannel], *, multi_instance: bool = False) -> ChannelPlugin: runtime_attr = f"_runtime_{channel_cls.display_name.lower()}" globals()[runtime_attr] = channel_cls setup = ChannelSetupSpec(fields={}) if multi_instance else None management = ( ChannelManagementSpec( multi_instance=True, instance_specs=_multi_instance_specs, update_instance_config=lambda section, values, *, instance_id="default": values, runtime_name=lambda name, instance_id: ( name if instance_id == "default" else f"{name}.{instance_id}" ), ) if multi_instance else ChannelManagementSpec() ) return ChannelPlugin( name=channel_cls.name, display_name=channel_cls.display_name, runtime=f"{__name__}:{runtime_attr}", setup=setup, management=management, ) def _stub_registry(monkeypatch, *plugins: ChannelPlugin) -> None: by_name = {plugin.name: plugin for plugin in plugins} monkeypatch.setattr( "nanobot.channels.registry.discover_plugins", lambda enabled_names=None: { name: plugin for name, plugin in by_name.items() if enabled_names is None or name in enabled_names }, ) def test_descriptor_rejects_runtime_class_owned_by_another_name(): plugin = ChannelPlugin( name="alias", display_name="Alias", runtime=f"{__name__}:_AliasHotChannel", ) with pytest.raises(ImportError, match="runtime declares name 'hot'"): plugin.load_channel_class() @pytest.mark.asyncio async def test_apply_channel_feature_action_starts_and_stops_channel(monkeypatch): disabled = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "hot": {"enabled": False}, } }) enabled = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "hot": {"enabled": True}, } }) configs = iter([enabled, disabled]) _stub_registry(monkeypatch, _plugin(_HotChannel)) monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs)) manager = ChannelManager(disabled, MessageBus()) manager._started = True enabled_result = await manager.apply_channel_feature_action("enable", "hot") assert enabled_result["handled"] is True assert enabled_result["requires_restart"] is False channel = manager.channels["hot"] await asyncio.wait_for(channel.started.wait(), timeout=1) assert channel.is_running is True disabled_result = await manager.apply_channel_feature_action("disable", "hot") assert disabled_result["handled"] is True assert disabled_result["requires_restart"] is False assert "hot" not in manager.channels assert channel.is_running is False @pytest.mark.asyncio async def test_apply_channel_feature_action_keeps_running_channel_when_rebuild_fails(monkeypatch): enabled = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "hot": {"enabled": True}, } }) _stub_registry(monkeypatch, _plugin(_HotChannel)) monkeypatch.setattr("nanobot.config.loader.load_config", lambda: enabled) manager = ChannelManager(enabled, MessageBus()) old_channel = manager.channels["hot"] old_channel._running = True def fail_build(*_args, **_kwargs): raise RuntimeError("invalid replacement config") monkeypatch.setattr(manager, "_build_channel", fail_build) result = await manager.apply_channel_feature_action("enable", "hot") assert result["requires_restart"] is False assert result["ok"] is False assert manager.channels["hot"] is old_channel assert old_channel.is_running is True assert not old_channel.stopped.is_set() @pytest.mark.asyncio async def test_apply_channel_feature_action_uses_channel_runtime_name(monkeypatch): config = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "multi": { "enabled": True, "instances": [ {"id": "default", "enabled": True}, {"id": "product", "enabled": True}, ] }, } }) _stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True)) monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) manager = ChannelManager(config, MessageBus()) product = manager.channels["multi.product"] product._running = True result = await manager.apply_channel_feature_action("disable", "multi", "product") assert result["requires_restart"] is False assert "multi" in manager.channels assert "multi.product" not in manager.channels assert product.is_running is False @pytest.mark.asyncio async def test_default_multi_channel_action_reconciles_only_default_runtime(monkeypatch): initial = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "multi": { "enabled": True, "instances": [ {"id": "default", "enabled": True}, {"id": "product", "enabled": True}, ], }, } }) disabled = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "multi": { "enabled": True, "instances": [ {"id": "default", "enabled": False}, {"id": "product", "enabled": True}, ], }, } }) enabled = Config.model_validate({ "channels": { "websocket": {"enabled": False}, "multi": { "enabled": True, "instances": [ {"id": "default", "enabled": True}, {"id": "product", "enabled": True}, ], }, } }) _stub_registry(monkeypatch, _plugin(_MultiHotChannel, multi_instance=True)) configs = iter([disabled, enabled]) monkeypatch.setattr("nanobot.config.loader.load_config", lambda: next(configs)) manager = ChannelManager(initial, MessageBus()) default = manager.channels["multi"] product = manager.channels["multi.product"] disabled_result = await manager.apply_channel_feature_action("disable", "multi") assert disabled_result["requires_restart"] is False assert set(manager.channels) == {"multi.product"} assert default.stopped.is_set() assert not product.stopped.is_set() enabled_result = await manager.apply_channel_feature_action("enable", "multi") assert enabled_result["requires_restart"] is False assert set(manager.channels) == {"multi", "multi.product"} assert manager.channels["multi.product"] is product