Files
MoviePilot/tests/test_plugin_instance_oper_listing.py
Aqr-K 4dbb8b8204 feat(plugin): 插件配置与实例描述符迁入独立表
插件配置此前寄存在 systemconfig 的 plugin.<实例ID> 单键下,实例描述符则整份挤在
PluginInstances 这一个 JSON 键里,两者都不成立:

- plugin.<ID> 是裸字符串 key,而仓库规则本身禁止用裸字符串做 SystemConfig key,
  只允许先定义 SystemConfigKey 枚举项;插件 ID 由用户安装决定,永远枚举不出常量。
  条目数随安装量增长,混在系统设置表里会把主程序自己的设置项淹掉。
- 那个键存的其实是实例 ID,表里没有任何一列说得出它属于哪个插件,想列出某个插件
  的全部实例配置只能靠字符串前缀去猜。
- 实例描述符整份读出再整份写回,改一个分身要重写全部分身。

改为 plugininstance 一实例一行:instance_id 与 source_plugin_id 构成身份,本体与
分身由两者是否相等派生而不设模式列——模式列会是这个等式的冗余副本,两者一旦失步
同一行就会在不同读取口被判成不同角色。展示信息与业务参数同存一行,属于同一个
生命周期,分表只会让建分身、删分身退化成两张表之间的协调问题。

读写路由落在 SystemConfigOper.get/set/delete,而不是在各个调用方各改一处:第三方
插件可能直接用 self.systemconfig.get("plugin.xxx") 读写自己的配置,只改
PluginConfigStore 与 _PluginBase.get_config/update_config 必然漏掉它们。路由只做
前缀识别,插入、更新与空本体行回收都委托 PluginInstanceOper,不在配置层重抄一遍。

PluginInstances 旧键迁移后不删,留作回滚依据,并以
SystemConfigKey.PluginInstancesImported 标志防止重复导入;判据不能是「表当前为空」,
否则用户把分身全部删光后,下次启动会把它们整批导回来。

迁移链:b2d4f6a8c1e3 -> 281965691a20(3.0.34 建表并搬描述符)
-> c4e1a7b9d2f6(3.0.35 加 config_data 并搬 plugin.* 配置)。

依赖基线与启动模块数随两个新模块重算,架构文档与数据库技能表目录同步登记新表。
2026-09-12 17:39:36 -04:00

75 lines
2.6 KiB
Python

"""插件实例列举在独占事务下的结果物化测试。"""
from __future__ import annotations
import pytest
from sqlalchemy import delete
from sqlalchemy.orm import Session
from app.db.models.plugininstance import PluginInstance
from app.db.oper.plugininstance import PluginInstanceOper
from app.db.uow import run_sync_transaction
_INSTANCE_IDS = ("DemoPlugin", "DemoPlugin@clone1", "OtherPlugin@clone1")
@pytest.fixture(autouse=True)
def purge_written_rows():
"""删除用例写入的行,测试库在整个会话内共享,残留会干扰后续用例。"""
yield
def purge(session: Session) -> None:
"""按本文件使用的固定标识精确删除,不触碰其他用例的数据。"""
session.execute(
delete(PluginInstance).where(
PluginInstance.instance_id.in_(_INSTANCE_IDS)
)
)
run_sync_transaction(purge)
def test_list_all_materializes_rows_before_session_close():
"""表中有记录时 list_all 必须返回已物化的实例,而不是关闭会话后的游标。"""
oper = PluginInstanceOper()
oper.save(instance_id="DemoPlugin", source_plugin_id="DemoPlugin")
oper.save(instance_id="DemoPlugin@clone1", source_plugin_id="DemoPlugin")
records = oper.list_all()
assert {record.instance_id for record in records} >= {
"DemoPlugin",
"DemoPlugin@clone1",
}
def test_list_by_source_materializes_rows_before_session_close():
"""按源插件列举同样要在会话关闭前取完行。"""
oper = PluginInstanceOper()
oper.save(instance_id="OtherPlugin@clone1", source_plugin_id="OtherPlugin")
records = oper.list_by_source("OtherPlugin")
assert [record.instance_id for record in records] == ["OtherPlugin@clone1"]
def test_save_rejects_flipping_an_existing_row_between_virtual_and_host():
"""分身与本体互不转换,改写已存在行的归属必须在持久化层被拒绝。
角色由 instance_id 是否等于 source_plugin_id 派生,把归属改成实例 ID 自身正是
把一个分身就地变成本体,只可能来自调用方错把分身实例 ID 当作源插件 ID 使用。
"""
oper = PluginInstanceOper()
oper.save(instance_id="DemoPlugin@clone1", source_plugin_id="DemoPlugin")
with pytest.raises(ValueError):
oper.save(
instance_id="DemoPlugin@clone1",
source_plugin_id="DemoPlugin@clone1",
)
preserved = oper.get("DemoPlugin@clone1")
assert preserved is not None
assert preserved.is_host is False
assert preserved.source_plugin_id == "DemoPlugin"