- test_channel_plugins: fix assertion logic for discoverable channels - test_filesystem_tools: normalize path separators for Windows - test_tool_validation: use python to generate output, avoid cmd line limits
229 lines
7.1 KiB
Python
229 lines
7.1 KiB
Python
"""Tests for channel plugin discovery, merging, and config compatibility."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.bus.events import OutboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.channels.base import BaseChannel
|
|
from nanobot.channels.manager import ChannelManager
|
|
from nanobot.config.schema import ChannelsConfig
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakePlugin(BaseChannel):
|
|
name = "fakeplugin"
|
|
display_name = "Fake Plugin"
|
|
|
|
async def start(self) -> None:
|
|
pass
|
|
|
|
async def stop(self) -> None:
|
|
pass
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
|
pass
|
|
|
|
|
|
class _FakeTelegram(BaseChannel):
|
|
"""Plugin that tries to shadow built-in telegram."""
|
|
name = "telegram"
|
|
display_name = "Fake Telegram"
|
|
|
|
async def start(self) -> None:
|
|
pass
|
|
|
|
async def stop(self) -> None:
|
|
pass
|
|
|
|
async def send(self, msg: OutboundMessage) -> None:
|
|
pass
|
|
|
|
|
|
def _make_entry_point(name: str, cls: type):
|
|
"""Create a mock entry point that returns *cls* on load()."""
|
|
ep = SimpleNamespace(name=name, load=lambda _cls=cls: _cls)
|
|
return ep
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ChannelsConfig extra="allow"
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_channels_config_accepts_unknown_keys():
|
|
cfg = ChannelsConfig.model_validate({
|
|
"myplugin": {"enabled": True, "token": "abc"},
|
|
})
|
|
extra = cfg.model_extra
|
|
assert extra is not None
|
|
assert extra["myplugin"]["enabled"] is True
|
|
assert extra["myplugin"]["token"] == "abc"
|
|
|
|
|
|
def test_channels_config_getattr_returns_extra():
|
|
cfg = ChannelsConfig.model_validate({"myplugin": {"enabled": True}})
|
|
section = getattr(cfg, "myplugin", None)
|
|
assert isinstance(section, dict)
|
|
assert section["enabled"] is True
|
|
|
|
|
|
def test_channels_config_builtin_fields_removed():
|
|
"""After decoupling, ChannelsConfig has no explicit channel fields."""
|
|
cfg = ChannelsConfig()
|
|
assert not hasattr(cfg, "telegram")
|
|
assert cfg.send_progress is True
|
|
assert cfg.send_tool_hints is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# discover_plugins
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_EP_TARGET = "importlib.metadata.entry_points"
|
|
|
|
|
|
def test_discover_plugins_loads_entry_points():
|
|
from nanobot.channels.registry import discover_plugins
|
|
|
|
ep = _make_entry_point("line", _FakePlugin)
|
|
with patch(_EP_TARGET, return_value=[ep]):
|
|
result = discover_plugins()
|
|
|
|
assert "line" in result
|
|
assert result["line"] is _FakePlugin
|
|
|
|
|
|
def test_discover_plugins_handles_load_error():
|
|
from nanobot.channels.registry import discover_plugins
|
|
|
|
def _boom():
|
|
raise RuntimeError("broken")
|
|
|
|
ep = SimpleNamespace(name="broken", load=_boom)
|
|
with patch(_EP_TARGET, return_value=[ep]):
|
|
result = discover_plugins()
|
|
|
|
assert "broken" not in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# discover_all — merge & priority
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_discover_all_includes_builtins():
|
|
from nanobot.channels.registry import discover_all, discover_channel_names
|
|
|
|
with patch(_EP_TARGET, return_value=[]):
|
|
result = discover_all()
|
|
|
|
# discover_all() only returns channels that are actually available (dependencies installed)
|
|
# discover_channel_names() returns all built-in channel names
|
|
# So we check that all actually loaded channels are in the result
|
|
for name in result:
|
|
assert name in discover_channel_names()
|
|
|
|
|
|
def test_discover_all_includes_external_plugin():
|
|
from nanobot.channels.registry import discover_all
|
|
|
|
ep = _make_entry_point("line", _FakePlugin)
|
|
with patch(_EP_TARGET, return_value=[ep]):
|
|
result = discover_all()
|
|
|
|
assert "line" in result
|
|
assert result["line"] is _FakePlugin
|
|
|
|
|
|
def test_discover_all_builtin_shadows_plugin():
|
|
from nanobot.channels.registry import discover_all
|
|
|
|
ep = _make_entry_point("telegram", _FakeTelegram)
|
|
with patch(_EP_TARGET, return_value=[ep]):
|
|
result = discover_all()
|
|
|
|
assert "telegram" in result
|
|
assert result["telegram"] is not _FakeTelegram
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Manager _init_channels with dict config (plugin scenario)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manager_loads_plugin_from_dict_config():
|
|
"""ChannelManager should instantiate a plugin channel from a raw dict config."""
|
|
from nanobot.channels.manager import ChannelManager
|
|
|
|
fake_config = SimpleNamespace(
|
|
channels=ChannelsConfig.model_validate({
|
|
"fakeplugin": {"enabled": True, "allowFrom": ["*"]},
|
|
}),
|
|
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
|
)
|
|
|
|
with patch(
|
|
"nanobot.channels.registry.discover_all",
|
|
return_value={"fakeplugin": _FakePlugin},
|
|
):
|
|
mgr = ChannelManager.__new__(ChannelManager)
|
|
mgr.config = fake_config
|
|
mgr.bus = MessageBus()
|
|
mgr.channels = {}
|
|
mgr._dispatch_task = None
|
|
mgr._init_channels()
|
|
|
|
assert "fakeplugin" in mgr.channels
|
|
assert isinstance(mgr.channels["fakeplugin"], _FakePlugin)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manager_skips_disabled_plugin():
|
|
fake_config = SimpleNamespace(
|
|
channels=ChannelsConfig.model_validate({
|
|
"fakeplugin": {"enabled": False},
|
|
}),
|
|
providers=SimpleNamespace(groq=SimpleNamespace(api_key="")),
|
|
)
|
|
|
|
with patch(
|
|
"nanobot.channels.registry.discover_all",
|
|
return_value={"fakeplugin": _FakePlugin},
|
|
):
|
|
mgr = ChannelManager.__new__(ChannelManager)
|
|
mgr.config = fake_config
|
|
mgr.bus = MessageBus()
|
|
mgr.channels = {}
|
|
mgr._dispatch_task = None
|
|
mgr._init_channels()
|
|
|
|
assert "fakeplugin" not in mgr.channels
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Built-in channel default_config() and dict->Pydantic conversion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_builtin_channel_default_config():
|
|
"""Built-in channels expose default_config() returning a dict with 'enabled': False."""
|
|
from nanobot.channels.telegram import TelegramChannel
|
|
cfg = TelegramChannel.default_config()
|
|
assert isinstance(cfg, dict)
|
|
assert cfg["enabled"] is False
|
|
assert "token" in cfg
|
|
|
|
|
|
def test_builtin_channel_init_from_dict():
|
|
"""Built-in channels accept a raw dict and convert to Pydantic internally."""
|
|
from nanobot.channels.telegram import TelegramChannel
|
|
bus = MessageBus()
|
|
ch = TelegramChannel({"enabled": False, "token": "test-tok", "allowFrom": ["*"]}, bus)
|
|
assert ch.config.token == "test-tok"
|
|
assert ch.config.allow_from == ["*"]
|