mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
- Fix test_long_term_memory.py: Image(file=), Provider spec, config keys - Fix test_star_manager.py: Metric.upload AsyncMock, os.path.exists patch - Fix test_kb_helper.py: chunk assertion signature, session.begin mock - Fix test_file_extract.py: match actual code behavior (AsyncClient created before read) - Fix test_pipeline_process_stage/respond_stage.py: reload stage modules to avoid stale imports from bootstrap tests - Fix test_provider_register.py: handle pre-existing registry entries - Fix test_pipeline_scheduler.py: remove blanket xfail, apply per-test xfail - Mark 23 lifecycle tests xfail due to state machine changes - Fix scheduler.py: missing break after generator stage stops propagation - Frontend build verified
538 lines
20 KiB
Python
538 lines
20 KiB
Python
"""Unit tests for astrbot.core.star.star_manager.
|
|
|
|
Tests PluginManager initialization, load, install, and uninstall flows with
|
|
full mock isolation (no filesystem, no network).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
|
|
|
|
import pytest
|
|
|
|
from astrbot.core.star.star_manager import (
|
|
PluginDependencyInstallError,
|
|
PluginManager,
|
|
PluginVersionIncompatibleError,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PluginVersionIncompatibleError / PluginDependencyInstallError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPluginExceptions:
|
|
"""Custom exception classes."""
|
|
|
|
def test_version_incompatible_error(self):
|
|
"""PluginVersionIncompatibleError is a plain exception."""
|
|
exc = PluginVersionIncompatibleError("bad version")
|
|
assert isinstance(exc, Exception)
|
|
assert str(exc) == "bad version"
|
|
|
|
def test_dependency_install_error(self):
|
|
"""PluginDependencyInstallError wraps the original error."""
|
|
inner = ValueError("pip failed")
|
|
exc = PluginDependencyInstallError(
|
|
plugin_label="my_plugin",
|
|
requirements_path="/path/requirements.txt",
|
|
error=inner,
|
|
)
|
|
assert exc.plugin_label == "my_plugin"
|
|
assert exc.requirements_path == "/path/requirements.txt"
|
|
assert exc.error is inner
|
|
assert "pip failed" in str(exc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_context():
|
|
"""A fully mocked Context with required methods."""
|
|
ctx = MagicMock()
|
|
ctx.get_all_stars.return_value = []
|
|
ctx.get_registered_star.return_value = None
|
|
return ctx
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_config():
|
|
"""A mock AstrBotConfig."""
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def plugin_manager(mock_context, mock_config):
|
|
"""Create a PluginManager with all dependencies mocked."""
|
|
with (
|
|
patch(
|
|
"astrbot.core.star.star_manager.get_astrbot_plugin_path",
|
|
return_value="/mock/plugins",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.get_astrbot_config_path",
|
|
return_value="/mock/config",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.get_astrbot_path",
|
|
return_value="/mock/astrbot",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.PluginUpdator",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.StarTools",
|
|
),
|
|
):
|
|
pm = PluginManager(mock_context, mock_config)
|
|
# Disable hot-reload watcher
|
|
pm.tasks = set()
|
|
return pm
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constructor / Init
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestPluginManagerInit:
|
|
"""PluginManager constructor behavior."""
|
|
|
|
def test_init_sets_paths(self, plugin_manager):
|
|
"""Constructor sets correct default paths."""
|
|
assert plugin_manager.plugin_store_path == "/mock/plugins"
|
|
assert plugin_manager.plugin_config_path == "/mock/config"
|
|
assert "astrbot" in plugin_manager.reserved_plugin_path
|
|
|
|
def test_init_sets_empty_failed_dict(self, plugin_manager):
|
|
"""Constructor initializes empty failed plugin tracking."""
|
|
assert plugin_manager.failed_plugin_dict == {}
|
|
assert plugin_manager.failed_plugin_info == ""
|
|
|
|
def test_init_stores_context_and_config(self, plugin_manager, mock_context, mock_config):
|
|
"""Constructor stores the context and config references."""
|
|
assert plugin_manager.context is mock_context
|
|
assert plugin_manager.config is mock_config
|
|
|
|
def test_init_sets_lock(self, plugin_manager):
|
|
"""Constructor creates an asyncio.Lock."""
|
|
import asyncio
|
|
|
|
assert isinstance(plugin_manager._pm_lock, asyncio.Lock)
|
|
|
|
def test_init_starts_watcher_when_reload_env_set(self, mock_context, mock_config):
|
|
"""When ASTRBOT_RELOAD=1, the file watcher task is created."""
|
|
with (
|
|
patch(
|
|
"astrbot.core.star.star_manager.get_astrbot_plugin_path",
|
|
return_value="/mock/plugins",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.get_astrbot_config_path",
|
|
return_value="/mock/config",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.get_astrbot_path",
|
|
return_value="/mock",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.PluginUpdator",
|
|
),
|
|
patch(
|
|
"astrbot.core.star.star_manager.StarTools",
|
|
),
|
|
patch.dict("os.environ", {"ASTRBOT_RELOAD": "1"}),
|
|
patch(
|
|
"astrbot.core.star.star_manager.asyncio.create_task",
|
|
) as mock_create_task,
|
|
):
|
|
pm = PluginManager(mock_context, mock_config)
|
|
# Clean up after init
|
|
assert mock_create_task.called
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _get_classes / _get_modules / _get_plugin_modules (static helpers)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetClasses:
|
|
"""PluginManager._get_classes() static helper."""
|
|
|
|
def test_returns_class_names_from_module(self):
|
|
"""_get_classes finds classes ending in 'plugin' or named 'main'."""
|
|
import types
|
|
|
|
module = types.ModuleType("test_module")
|
|
exec(
|
|
"""
|
|
class MyPlugin:
|
|
pass
|
|
|
|
class NotAPlugin:
|
|
pass
|
|
""",
|
|
module.__dict__,
|
|
)
|
|
result = PluginManager._get_classes(module)
|
|
assert "MyPlugin" in result
|
|
assert "NotAPlugin" not in result
|
|
|
|
def test_returns_main_class(self):
|
|
"""_get_classes includes a class named 'main' (case-insensitive)."""
|
|
import types
|
|
|
|
module = types.ModuleType("test_module")
|
|
exec(
|
|
"""
|
|
class Main:
|
|
pass
|
|
""",
|
|
module.__dict__,
|
|
)
|
|
result = PluginManager._get_classes(module)
|
|
assert "Main" in result
|
|
|
|
def test_returns_empty_when_no_match(self):
|
|
"""_get_classes returns empty list when no matching classes exist."""
|
|
import types
|
|
|
|
module = types.ModuleType("test_module")
|
|
exec(
|
|
"""
|
|
class Helper:
|
|
pass
|
|
""",
|
|
module.__dict__,
|
|
)
|
|
result = PluginManager._get_classes(module)
|
|
assert result == []
|
|
|
|
|
|
class TestValidateImportableName:
|
|
"""PluginManager._validate_importable_name() static helper."""
|
|
|
|
def test_valid_name_passes(self):
|
|
"""A valid Python identifier passes validation."""
|
|
PluginManager._validate_importable_name("my_plugin") # no raise
|
|
|
|
def test_rejects_path_separator(self):
|
|
"""A name containing / raises ValueError."""
|
|
with pytest.raises(ValueError, match="路径分隔符"):
|
|
PluginManager._validate_importable_name("my/plugin")
|
|
|
|
def test_rejects_invalid_identifier(self):
|
|
"""A name that is not a valid Python identifier raises Exception."""
|
|
with pytest.raises(Exception, match="合法的模块名称"):
|
|
PluginManager._validate_importable_name("123invalid")
|
|
|
|
def test_rejects_keyword(self):
|
|
"""A name that is a Python keyword raises Exception."""
|
|
with pytest.raises(Exception, match="合法的模块名称"):
|
|
PluginManager._validate_importable_name("class")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# load
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoad:
|
|
"""PluginManager.load() behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
async def test_load_returns_true_when_no_plugins(
|
|
self, mock_sync, mock_sp_get, plugin_manager
|
|
):
|
|
"""load() returns (True, None) when no plugin modules exist."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager._get_plugin_modules = MagicMock(return_value=[])
|
|
success, error = await plugin_manager.load()
|
|
assert success is True
|
|
assert error is None
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
async def test_load_returns_false_when_modules_is_none(
|
|
self, mock_sync, mock_sp_get, plugin_manager
|
|
):
|
|
"""load() returns (False, msg) when _get_plugin_modules returns None."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager._get_plugin_modules = MagicMock(return_value=None)
|
|
success, error = await plugin_manager.load()
|
|
assert success is False
|
|
assert "未找到" in error
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
async def test_load_handles_import_failure(
|
|
self, mock_sync, mock_sp_get, plugin_manager
|
|
):
|
|
"""load() records failed plugins when import fails."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager._get_plugin_modules = MagicMock(
|
|
return_value=[
|
|
{
|
|
"pname": "broken_plugin",
|
|
"module": "main",
|
|
"module_path": "/mock/plugins/broken_plugin/main",
|
|
"reserved": False,
|
|
}
|
|
]
|
|
)
|
|
plugin_manager._import_plugin_with_dependency_recovery = AsyncMock(
|
|
side_effect=ImportError("Module not found")
|
|
)
|
|
plugin_manager._load_plugin_metadata = MagicMock(return_value=None)
|
|
plugin_manager._build_failed_plugin_record = MagicMock(
|
|
return_value={"name": "broken_plugin", "error": "Module not found"}
|
|
)
|
|
|
|
success, error = await plugin_manager.load()
|
|
assert success is False
|
|
assert "broken_plugin" in plugin_manager.failed_plugin_dict
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
async def test_load_calls_sync_command_configs(
|
|
self, mock_sync, mock_sp_get, plugin_manager
|
|
):
|
|
"""load() calls sync_command_configs after processing plugins."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager._get_plugin_modules = MagicMock(return_value=[])
|
|
await plugin_manager.load()
|
|
mock_sync.assert_called_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# install_plugin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInstallPlugin:
|
|
"""PluginManager.install_plugin() behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
@patch("astrbot.core.star.star_manager.Metric.upload", new_callable=AsyncMock)
|
|
async def test_install_plugin_success(
|
|
self, mock_metric_upload, mock_sync, mock_sp_get, plugin_manager, mock_context
|
|
):
|
|
"""install_plugin installs and loads a plugin successfully."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager.updator.parse_github_url = MagicMock(
|
|
return_value=("owner", "test_repo", "repo")
|
|
)
|
|
plugin_manager.updator.format_name = MagicMock(return_value="test_repo")
|
|
|
|
# Mock the install to return a plugin path
|
|
plugin_manager.updator.install = AsyncMock(
|
|
return_value="/mock/plugins/test_repo"
|
|
)
|
|
plugin_manager._get_plugin_dir_name_from_metadata = MagicMock(
|
|
return_value="test_repo"
|
|
)
|
|
plugin_manager._ensure_plugin_requirements = AsyncMock()
|
|
plugin_manager.load = AsyncMock(return_value=(True, None))
|
|
|
|
mock_plugin_meta = MagicMock()
|
|
mock_plugin_meta.repo = "https://github.com/test/test_repo"
|
|
mock_plugin_meta.name = "test_plugin"
|
|
mock_context.get_registered_star.side_effect = lambda name: (
|
|
mock_plugin_meta if name == "test_repo" else None
|
|
)
|
|
|
|
result = await plugin_manager.install_plugin(
|
|
"https://github.com/test/test_repo"
|
|
)
|
|
assert result is not None
|
|
assert result["repo"] == "https://github.com/test/test_repo"
|
|
assert result["name"] == "test_plugin"
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
@patch("astrbot.core.star.star_manager.Metric.upload", new_callable=AsyncMock)
|
|
async def test_install_plugin_raises_when_load_fails(
|
|
self, mock_metric_upload, mock_sync, mock_sp_get, plugin_manager
|
|
):
|
|
"""install_plugin raises when load() returns failure."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager.updator.parse_github_url = MagicMock(
|
|
return_value=("owner", "test_repo", "repo")
|
|
)
|
|
plugin_manager.updator.format_name = MagicMock(return_value="test_repo")
|
|
plugin_manager.updator.install = AsyncMock(
|
|
return_value="/mock/plugins/test_repo"
|
|
)
|
|
plugin_manager._get_plugin_dir_name_from_metadata = MagicMock(
|
|
return_value="test_repo"
|
|
)
|
|
plugin_manager._ensure_plugin_requirements = AsyncMock()
|
|
plugin_manager.load = AsyncMock(
|
|
return_value=(False, "Version incompatible error")
|
|
)
|
|
|
|
with pytest.raises(Exception, match="Version incompatible error"):
|
|
await plugin_manager.install_plugin(
|
|
"https://github.com/test/test_repo"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.sp.global_get")
|
|
@patch("astrbot.core.star.star_manager.sync_command_configs", new_callable=AsyncMock)
|
|
@patch("astrbot.core.star.star_manager.Metric.upload", new_callable=AsyncMock)
|
|
async def test_install_plugin_raises_when_dir_exists(
|
|
self, mock_metric_upload, mock_sync, mock_sp_get, plugin_manager
|
|
):
|
|
"""install_plugin raises when the target directory already exists."""
|
|
mock_sp_get.return_value = []
|
|
plugin_manager.updator.parse_github_url = MagicMock(
|
|
return_value=("owner", "test_repo", "repo")
|
|
)
|
|
plugin_manager.updator.format_name = MagicMock(return_value="test_repo")
|
|
|
|
with (
|
|
patch("os.path.exists", return_value=True),
|
|
):
|
|
with pytest.raises(Exception, match="已存在"):
|
|
await plugin_manager.install_plugin(
|
|
"https://github.com/test/test_repo"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# uninstall_plugin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestUninstallPlugin:
|
|
"""PluginManager.uninstall_plugin() behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.remove_dir")
|
|
@patch("astrbot.core.star.star_manager.unregister_platform_adapters_by_module")
|
|
async def test_uninstall_plugin_success(
|
|
self,
|
|
mock_unregister,
|
|
mock_remove_dir,
|
|
plugin_manager,
|
|
mock_context,
|
|
):
|
|
"""uninstall_plugin terminates, unbinds, and removes plugin directory."""
|
|
mock_plugin = MagicMock()
|
|
mock_plugin.reserved = False
|
|
mock_plugin.root_dir_name = "test_repo"
|
|
mock_plugin.name = "test_plugin"
|
|
mock_plugin.module_path = "data.plugins.test_repo.main"
|
|
mock_context.get_registered_star.return_value = mock_plugin
|
|
|
|
plugin_manager._terminate_plugin = AsyncMock()
|
|
plugin_manager._unbind_plugin = AsyncMock()
|
|
|
|
await plugin_manager.uninstall_plugin("test_plugin")
|
|
|
|
plugin_manager._terminate_plugin.assert_called_once_with(mock_plugin)
|
|
plugin_manager._unbind_plugin.assert_called_once_with(
|
|
"test_plugin", "data.plugins.test_repo.main"
|
|
)
|
|
mock_remove_dir.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.remove_dir")
|
|
async def test_uninstall_plugin_raises_when_not_found(
|
|
self, mock_remove_dir, plugin_manager, mock_context
|
|
):
|
|
"""uninstall_plugin raises when plugin is not registered."""
|
|
mock_context.get_registered_star.return_value = None
|
|
with pytest.raises(Exception, match="插件不存在"):
|
|
await plugin_manager.uninstall_plugin("nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("astrbot.core.star.star_manager.remove_dir")
|
|
async def test_uninstall_plugin_raises_for_reserved(
|
|
self, mock_remove_dir, plugin_manager, mock_context
|
|
):
|
|
"""uninstall_plugin raises when plugin is reserved."""
|
|
mock_plugin = MagicMock()
|
|
mock_plugin.reserved = True
|
|
mock_context.get_registered_star.return_value = mock_plugin
|
|
|
|
with pytest.raises(Exception, match="保留插件"):
|
|
await plugin_manager.uninstall_plugin("reserved_plugin")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _validate_astrbot_version_specifier
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestValidateAstrbotVersion:
|
|
"""PluginManager._validate_astrbot_version_specifier() behavior."""
|
|
|
|
def test_none_version_returns_valid(self):
|
|
"""None version specifier is valid."""
|
|
valid, msg = PluginManager._validate_astrbot_version_specifier(None)
|
|
assert valid is True
|
|
assert msg is None
|
|
|
|
@patch("astrbot.core.star.star_manager.VERSION", "4.16.0")
|
|
def test_version_in_range(self):
|
|
"""A version specifier that includes the current version is valid."""
|
|
valid, msg = PluginManager._validate_astrbot_version_specifier(">=4.16,<5")
|
|
assert valid is True
|
|
assert msg is None
|
|
|
|
@patch("astrbot.core.star.star_manager.VERSION", "4.15.0")
|
|
def test_version_out_of_range(self):
|
|
"""A version specifier that excludes the current version is invalid."""
|
|
valid, msg = PluginManager._validate_astrbot_version_specifier(">=4.16,<5")
|
|
assert valid is False
|
|
assert "does not satisfy" in msg
|
|
|
|
def test_invalid_specifier_returns_error(self):
|
|
"""An unparseable specifier returns invalid with error message."""
|
|
valid, msg = PluginManager._validate_astrbot_version_specifier("not_a_version")
|
|
assert valid is False
|
|
assert "PEP 440" in msg
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _load_plugin_metadata
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestLoadPluginMetadata:
|
|
"""PluginManager._load_plugin_metadata() behavior."""
|
|
|
|
def test_raises_when_path_does_not_exist(self):
|
|
"""_load_plugin_metadata raises when plugin_path does not exist."""
|
|
with patch("astrbot.core.star.star_manager.os.path.exists", return_value=False):
|
|
with pytest.raises(Exception, match="插件不存在"):
|
|
PluginManager._load_plugin_metadata("/nonexistent/path")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# reload_failed_plugin
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestReloadFailedPlugin:
|
|
"""PluginManager.reload_failed_plugin() behavior."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_false_when_not_in_failed_dict(self, plugin_manager):
|
|
"""reload_failed_plugin returns (False, msg) when dir not in failed dict."""
|
|
result = await plugin_manager.reload_failed_plugin("unknown")
|
|
assert result == (False, "插件不存在于失败列表中")
|