Files
MoviePilot/tests/test_plugin_clone_experience.py

613 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""分身创建体验测试:自动分配后缀、按 ID 恢复已停用的分身与提交前的后缀校验。
这些行为大多只在 ``build_plugin_runtime`` 装配出来的整体上成立:分身服务自己只认注入
进来的端口,「哪些 ID 算被占用」是组合根的决定,因而占用判据必须在这一层验证,而不是
在用例里另接一套判据自证。
"""
from __future__ import annotations
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from typing import Any, Optional
import pytest
from pydantic import ValidationError
from app.runtime.extensions.plugin.manager import PluginManager
from app.runtime.extensions.plugin.runtime import (
PluginRuntime,
PluginRuntimeEnvironment,
build_plugin_runtime,
)
from app.runtime.extensions.plugin.storage import (
PluginInstanceDirectory,
PluginStorage,
)
from app.schemas.plugin import PluginCloneRequest, PluginInstance, PluginRuntimeStatus
from app.schemas.types import SystemConfigKey
class DemoPlugin:
"""充当源插件本体的最小插件类,只需要能被注册表登记。"""
def _logger() -> SimpleNamespace:
"""提供运行时构造所需的最小日志对象。"""
return SimpleNamespace(
debug=lambda *_args, **_kwargs: None,
info=lambda *_args, **_kwargs: None,
warning=lambda *_args, **_kwargs: None,
error=lambda *_args, **_kwargs: None,
)
class _World:
"""一套全内存的插件世界,暴露用例要断言的每一份真实状态。"""
def __init__(self) -> None:
"""建立空的实例表、配置表与运行态记录。"""
self.rows: dict[str, PluginInstance] = {}
self.configs: dict[str, dict] = {}
self.installed: list[str] = []
self.plugins_root = Path("/nonexistent-plugins-root")
self.reloaded: list[str] = []
self.removed: list[str] = []
self.status = PluginRuntimeStatus.ACTIVE
self.probe_delay = 0.0
self.runtime: Optional[PluginRuntime] = None
def clone(self, **kwargs: Any) -> tuple[bool, str]:
"""按默认展示信息发起一次分身创建。"""
assert self.runtime is not None
params: dict[str, Any] = {"name": "", "description": ""}
params.update(kwargs)
return self.runtime.clone.clone(**params)
def restorable(self) -> list[dict[str, Any]]:
"""调用管理器上的可恢复清单投影。
``get_restorable_plugin_instances`` 是一段纯投影,只用到实例表与配置表两个
属性;用一个替身 self 调用它,既验证真实投影逻辑,又不必把 PluginManager
这个单例连同它的文件监控与线程池一起启动起来。
"""
assert self.runtime is not None
stand_in = SimpleNamespace(
_plugin_instance_store=self.runtime.instances,
_plugin_config_store=self.runtime.configs,
)
return PluginManager.get_restorable_plugin_instances(stand_in, "DemoPlugin")
def _build_world(
*,
rows: Optional[dict[str, PluginInstance]] = None,
configs: Optional[dict[str, dict]] = None,
installed: Optional[list[str]] = None,
plugins_root: Optional[Path] = None,
) -> _World:
"""按给定实例行、配置、安装清单与插件包目录装配一个全内存运行时。"""
world = _World()
world.rows.update(rows or {})
world.configs.update(configs or {})
world.installed.extend(installed or [])
if plugins_root is not None:
world.plugins_root = plugins_root
values: dict = {SystemConfigKey.UserInstalledPlugins: world.installed}
def _read_row(instance_id: str) -> Optional[PluginInstance]:
"""读取实例行,可按需放慢以拉开并发创建的竞争窗口。"""
if world.probe_delay:
time.sleep(world.probe_delay)
return world.rows.get(instance_id)
def _set_enabled(instance_id: str, is_enabled: bool) -> bool:
"""就地翻转启用位,行不存在时报告未写入。"""
record = world.rows.get(instance_id)
if record is None:
return False
world.rows[instance_id] = record.model_copy(update={"is_enabled": is_enabled})
return True
storage = PluginStorage(
read=values.get,
write=values.__setitem__,
read_config=world.configs.get,
write_config=lambda instance_id, config: world.configs.__setitem__(
instance_id,
config,
),
delete_config=lambda instance_id: world.configs.pop(instance_id, None) is not None,
)
directory = PluginInstanceDirectory(
get=_read_row,
list_all=lambda: list(world.rows.values()),
list_by_source=lambda source_plugin_id: [
record
for record in world.rows.values()
if record.source_plugin_id == source_plugin_id
],
save=lambda instance: world.rows.__setitem__(instance.instance_id, instance),
delete=lambda instance_id: world.rows.pop(instance_id, None) is not None,
list_enabled=lambda: [
record for record in world.rows.values() if record.is_enabled
],
set_enabled=_set_enabled,
)
def _reload(plugin_id: str) -> PluginRuntimeStatus:
"""记录一次定向重载并回报当前世界设定的结果。"""
world.reloaded.append(plugin_id)
return world.status
environment = PluginRuntimeEnvironment(
plugins_root=world.plugins_root,
storage=lambda: storage,
instance_directory=lambda: directory,
system=lambda: SimpleNamespace(),
database=lambda: SimpleNamespace(),
catalog_factory=lambda _mapper: SimpleNamespace(),
import_preparer=lambda **_kwargs: None,
import_scanner=lambda **_kwargs: None,
auth_level=lambda: 0,
remote_entry=lambda _plugin_id, _page: "",
development=lambda: False,
logger=_logger(),
set_default_target=lambda _plugin_id, _instance_id: True,
clear_default_target=lambda _plugin_id: None,
)
runtime = build_plugin_runtime(
SimpleNamespace(
reload_plugin=_reload,
remove_plugin=world.removed.append,
get_plugin_remote_entry=lambda _plugin_id, _page: "",
_run_file_watcher=lambda: None,
get_plugins_from_market=lambda *_args, **_kwargs: None,
async_get_plugins_from_market=lambda *_args, **_kwargs: None,
),
environment,
tool_build_max_attempts=1,
)
# 源插件本体必须在类注册表里:分身共享它的源码,它缺席就谈不上给它建分身
runtime.registry.classes["DemoPlugin"] = DemoPlugin
world.runtime = runtime
return world
def _disabled_clone(instance_id: str, **fields: Any) -> PluginInstance:
"""构造一行已停用的分身,模拟「停用后设置仍留存」的状态。"""
payload: dict[str, Any] = {
"instance_id": instance_id,
"source_plugin_id": "DemoPlugin",
"is_enabled": False,
}
payload.update(fields)
return PluginInstance(**payload)
# --------------------------------------------------------------------------- #
# 自动分配后缀
# --------------------------------------------------------------------------- #
def test_auto_allocated_suffix_starts_right_after_the_host_instance():
"""不填后缀时从 2 起算:本体在用户眼里就是第 1 个实例,分身接着往下排。"""
world = _build_world()
success, instance_id = world.clone(plugin_id="DemoPlugin")
assert success is True
assert instance_id == "DemoPlugin2"
assert world.rows["DemoPlugin2"].is_enabled is True
def test_auto_allocated_suffix_skips_a_disabled_clone_row():
"""已停用的分身同样占号,自动分配不得挑中它。
重用它的 ID 是「恢复」而不是新建:用户点的是「新建一个」,却拿到上一个分身留下
的业务参数,等于凭空继承了一份他没打算要的配置。
"""
world = _build_world(
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2")},
configs={"DemoPlugin2": {"token": "旧的"}},
)
success, instance_id = world.clone(plugin_id="DemoPlugin")
assert success is True
assert instance_id == "DemoPlugin3"
# 旧行原样留着,没有被这次新建顶掉
assert world.rows["DemoPlugin2"].is_enabled is False
assert world.configs["DemoPlugin2"] == {"token": "旧的"}
def test_auto_allocated_suffix_skips_an_enabled_clone_row():
"""在册的分身占号,自动分配跳过它继续往下找。"""
world = _build_world(
rows={
"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True),
"DemoPlugin3": _disabled_clone("DemoPlugin3", is_enabled=True),
},
)
success, instance_id = world.clone(plugin_id="DemoPlugin")
assert success is True
assert instance_id == "DemoPlugin4"
def test_auto_allocated_suffix_skips_an_installed_physical_plugin():
"""装过但此刻未装载的物理插件同样占号。
它不在类注册表里,只看运行态会把 ``DemoPlugin2`` 判成空位,新分身于是顶着一个
真实插件的身份建出来,那个插件下次装载时两者撞在同一个 ID 上。
"""
world = _build_world(installed=["DemoPlugin", "DemoPlugin2"])
success, instance_id = world.clone(plugin_id="DemoPlugin")
assert success is True
assert instance_id == "DemoPlugin3"
def test_auto_allocated_suffix_skips_a_plugin_package_present_on_disk(tmp_path):
"""磁盘上留着同名插件包时不得占用该号,否则那个插件以后再也装不回来。
卸载不删源码,包目录会一直留着;它既不在类注册表里,也不在安装清单里,只看这两
处会把它判成空位。分身占了这个号之后,那个插件重装时实例行的归属列对不上,写入
会被持久化层直接拒绝。
"""
(tmp_path / "demoplugin2").mkdir()
world = _build_world(plugins_root=tmp_path)
success, instance_id = world.clone(plugin_id="DemoPlugin")
assert success is True
assert instance_id == "DemoPlugin3"
def test_auto_allocated_suffix_skips_a_currently_loaded_plugin_class():
"""运行期已登记同名类时不得占用该号。"""
world = _build_world()
world.runtime.registry.classes["DemoPlugin2"] = DemoPlugin
success, instance_id = world.clone(plugin_id="DemoPlugin")
assert success is True
assert instance_id == "DemoPlugin3"
@pytest.mark.parametrize("blank", ["", " ", None])
def test_blank_suffix_falls_back_to_automatic_allocation(blank):
"""后缀留空的几种写法都走自动分配,而不是拼出一个等于源插件 ID 的实例。"""
world = _build_world()
success, instance_id = world.clone(plugin_id="DemoPlugin", suffix=blank)
assert success is True
assert instance_id == "DemoPlugin2"
# --------------------------------------------------------------------------- #
# 判存:同一个占用判据挡住显式后缀
# --------------------------------------------------------------------------- #
def test_explicit_suffix_colliding_with_an_enabled_clone_is_rejected():
"""显式后缀撞上在册分身时拒绝,且不得覆盖它的描述符。"""
world = _build_world(
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True, plugin_name="在跑的")},
)
success, message = world.clone(plugin_id="DemoPlugin", suffix="2", name="新的")
assert success is False
assert "已存在" in message
assert world.rows["DemoPlugin2"].plugin_name == "在跑的"
def test_explicit_suffix_colliding_with_another_sources_clone_is_rejected():
"""归属对不上的停用行不算可恢复:它是别的源插件的分身,不能被这里改嫁。"""
world = _build_world(
rows={
"DemoPlugin2": PluginInstance(
instance_id="DemoPlugin2",
source_plugin_id="OtherPlugin",
is_enabled=False,
),
},
)
success, message = world.clone(plugin_id="DemoPlugin", suffix="2")
assert success is False
assert "已存在" in message
assert world.rows["DemoPlugin2"].source_plugin_id == "OtherPlugin"
def test_a_clone_cannot_be_used_as_a_clone_source():
"""分身不能再生分身:它共享源插件的源码,自己并不是一份源码。"""
world = _build_world(rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True)})
world.runtime.registry.classes["DemoPlugin2"] = DemoPlugin
success, message = world.clone(plugin_id="DemoPlugin2", suffix="3")
assert success is False
assert "不能作为分身来源" in message
assert "DemoPlugin" in message
def test_repeating_the_same_create_is_rejected_rather_than_overwriting():
"""同一个后缀重复提交两次,第二次确定地被判成已存在。"""
world = _build_world()
first = world.clone(plugin_id="DemoPlugin", suffix="Work", name="第一次")
second = world.clone(plugin_id="DemoPlugin", suffix="Work", name="第二次")
assert first == (True, "DemoPluginwork")
assert second[0] is False
assert "已存在" in second[1]
assert world.rows["DemoPluginwork"].plugin_name == "第一次"
# --------------------------------------------------------------------------- #
# 按 ID 恢复已停用的分身
# --------------------------------------------------------------------------- #
def test_restoring_a_disabled_clone_brings_back_its_config_and_display_info():
"""按 ID 恢复就是把那一行重新置为启用,配置与展示信息原样回来。
停用从不删行,业务参数一直挂在上面;恢复因而不能是「新建一行再按源插件模板铺一份
配置」,那会把用户特意留着的东西盖掉。
"""
world = _build_world(
rows={
"DemoPlugin2": _disabled_clone(
"DemoPlugin2",
plugin_name="夜间任务",
plugin_desc="只在夜里跑",
plugin_icon="night.png",
),
},
configs={
"DemoPlugin": {"token": "源插件的"},
"DemoPlugin2": {"token": "分身自己的", "cron": "0 3 * * *"},
},
)
success, instance_id = world.clone(plugin_id="DemoPlugin", suffix="2")
assert (success, instance_id) == (True, "DemoPlugin2")
restored = world.rows["DemoPlugin2"]
assert restored.is_enabled is True
assert restored.plugin_name == "夜间任务"
assert restored.plugin_desc == "只在夜里跑"
assert restored.plugin_icon == "night.png"
# 配置没有被源插件模板顶掉,这正是用户要拿回来的东西
assert world.configs["DemoPlugin2"] == {"token": "分身自己的", "cron": "0 3 * * *"}
assert world.reloaded == ["DemoPlugin2"]
def test_restoring_accepts_a_new_display_name_while_keeping_the_config():
"""恢复时可以顺手改名,改的只是展示信息,业务参数仍然沿用。"""
world = _build_world(
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="旧名字")},
configs={"DemoPlugin2": {"token": "留着"}},
)
success, _instance_id = world.clone(
plugin_id="DemoPlugin",
suffix="2",
name="新名字",
)
assert success is True
assert world.rows["DemoPlugin2"].plugin_name == "新名字"
assert world.configs["DemoPlugin2"] == {"token": "留着"}
def test_restore_previous_false_rebuilds_the_config_from_the_source_template():
"""显式要求全新时按源插件模板重建配置,并保持业务开关关闭待用户配置。"""
world = _build_world(
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="旧名字")},
configs={
"DemoPlugin": {"enable": True, "token": "源插件的"},
"DemoPlugin2": {"token": "该被丢弃"},
},
)
success, _instance_id = world.clone(
plugin_id="DemoPlugin",
suffix="2",
name="全新",
restore_previous=False,
)
assert success is True
assert world.configs["DemoPlugin2"] == {
"enable": False,
"enabled": False,
"token": "源插件的",
}
assert world.rows["DemoPlugin2"].plugin_name == "全新"
def test_failed_restore_only_puts_the_row_back_to_disabled():
"""恢复失败只把启用位退回停用,不得连同用户留存的配置一起毁掉。
那一行是用户特意留着的,不是本次创建的产物;一次加载失败就删掉它,等于让用户为
一个可重试的故障付出丢数据的代价。
"""
world = _build_world(
rows={"DemoPlugin2": _disabled_clone("DemoPlugin2", plugin_name="夜间任务")},
configs={"DemoPlugin2": {"token": "必须留着"}},
)
world.status = PluginRuntimeStatus.LOAD_FAILED
success, message = world.clone(plugin_id="DemoPlugin", suffix="2")
assert success is False
assert "加载失败" in message
assert world.rows["DemoPlugin2"].is_enabled is False
assert world.rows["DemoPlugin2"].plugin_name == "夜间任务"
assert world.configs["DemoPlugin2"] == {"token": "必须留着"}
assert world.removed == ["DemoPlugin2"]
def test_failed_fresh_creation_still_removes_the_row_it_created():
"""新建失败仍按老规矩连行带配置抹掉:它整个是本次的产物,留着只是垃圾。"""
world = _build_world(configs={"DemoPlugin": {"token": "源插件的"}})
world.status = PluginRuntimeStatus.LOAD_FAILED
success, message = world.clone(plugin_id="DemoPlugin", suffix="Work")
assert success is False
assert "加载失败" in message
assert "DemoPluginwork" not in world.rows
assert "DemoPluginwork" not in world.configs
# --------------------------------------------------------------------------- #
# 校验在提交前拦截
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"suffix",
["a-b", "带中文", "work!", "with space", "x" * 21],
)
def test_illegal_suffix_is_rejected_by_the_request_schema(suffix):
"""非法后缀在请求解析阶段就被拦下,根本到不了会写库的那一层。"""
with pytest.raises(ValidationError):
PluginCloneRequest(suffix=suffix)
@pytest.mark.parametrize("blank", ["", " ", None])
def test_blank_suffix_is_normalized_to_automatic_allocation_by_the_schema(blank):
"""留空的三种写法在请求层归一为 None不会撞上格式校验。"""
assert PluginCloneRequest(suffix=blank).suffix is None
def test_request_schema_trims_and_keeps_a_legal_suffix():
"""合法后缀去掉首尾空白后原样保留,大小写留给运行时归一。"""
assert PluginCloneRequest(suffix=" Work ").suffix == "Work"
def test_restore_previous_defaults_to_reusing_the_stored_settings():
"""默认沿用留存设置:静默丢弃用户数据不能是默认行为。"""
assert PluginCloneRequest().restore_previous is True
def test_an_overlong_composed_instance_id_is_rejected_before_any_write():
"""拼出来的实例 ID 超长时在落库之前就被拒绝,不留下半个实例。
非法 ID 若等到持久化层才报错,意味着已经写出一行、还要靠回滚去擦掉它;把判定
提到构造阶段,失败路径上根本没有东西需要清理。
"""
long_id = "D" + "e" * 119
world = _build_world()
world.runtime.registry.classes[long_id] = DemoPlugin
success, message = world.clone(plugin_id=long_id, suffix="x" * 20)
assert success is False
assert "不合法" in message
assert world.rows == {}
assert world.configs == {}
assert world.removed == []
# --------------------------------------------------------------------------- #
# 并发创建
# --------------------------------------------------------------------------- #
def test_concurrent_auto_allocation_never_hands_out_the_same_instance_id():
"""两个并发的自动分配各自拿到一个号,不会落到同一行上。
「挑一个没被占用的 ID」与「把它占下来」之间存在窗口用例把占用探测放慢来把窗口
拉开,没有占位互斥时两个线程会选出同一个 ID后写的顶掉先写的。
"""
world = _build_world()
world.probe_delay = 0.005
barrier = threading.Barrier(2)
results: list[tuple[bool, str]] = []
lock = threading.Lock()
def _create() -> None:
"""两个线程在同一时刻发起创建。"""
barrier.wait(timeout=5)
outcome = world.clone(plugin_id="DemoPlugin")
with lock:
results.append(outcome)
threads = [threading.Thread(target=_create) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(timeout=10)
assert all(success for success, _ in results)
assert sorted(instance_id for _, instance_id in results) == [
"DemoPlugin2",
"DemoPlugin3",
]
assert sorted(world.rows) == ["DemoPlugin2", "DemoPlugin3"]
# --------------------------------------------------------------------------- #
# 可恢复清单
# --------------------------------------------------------------------------- #
def test_restorable_listing_only_reports_disabled_clones():
"""在册的分身与本体那一行都不在恢复清单里。
活着的分身配置正被使用,摆进恢复选择器只会让人误以为能把它再创建一遍;本体不是
分身,也没有「恢复成分身」这回事。
"""
world = _build_world(
rows={
"DemoPlugin": PluginInstance(
instance_id="DemoPlugin",
source_plugin_id="DemoPlugin",
is_enabled=False,
),
"DemoPlugin2": _disabled_clone("DemoPlugin2", is_enabled=True),
"DemoPlugin3": _disabled_clone("DemoPlugin3"),
},
)
assert [item["instance_id"] for item in world.restorable()] == ["DemoPlugin3"]
def test_restorable_listing_reports_suffix_display_info_and_stored_config():
"""清单给出后缀、展示信息与是否留有业务参数,供恢复选择器直接渲染。"""
world = _build_world(
rows={
"DemoPluginwork": _disabled_clone(
"DemoPluginwork",
plugin_name="工作实例",
plugin_desc="独立配置",
),
"DemoPlugin9": _disabled_clone("DemoPlugin9"),
},
configs={"DemoPluginwork": {"token": "留着"}},
)
listing = {item["instance_id"]: item for item in world.restorable()}
assert listing["DemoPluginwork"] == {
"instance_id": "DemoPluginwork",
"suffix": "work",
"plugin_name": "工作实例",
"plugin_desc": "独立配置",
"has_config": True,
}
# 停用的实例不在类注册表里,「有没有配置」不能走要求插件在册的读取口去问
assert listing["DemoPlugin9"]["has_config"] is False