Files
MoviePilot/tests/test_plugin_config_table.py
Aqr-K 3d594ecee3 refactor(plugin): 插件配置改走独立通道,SystemConfig 不再按键值分支
插件配置此前借用 systemconfig 的 plugin.<实例ID> 单键,为了换表又在
SystemConfigOper 的 set/delete/load_snapshot 里按该前缀开分支。这是反向依赖:
一个对所有键一视同仁的通用键值存储被迫知道「插件」这个概念,读写路径依键值而
异,「这个键落在哪张表」只有读代码才说得清;规范方法按值加特例,也让此后任何新
键都要先确认自己会不会被拦截。

本次把插件配置整条链拆出,形状与本分支日志等级那条链一致:

- SystemConfigOper 整体回退到官方实现,与 f70d7c141 逐字节相同,对所有键一视同仁。
- 配置端口改按实例 ID 寻址:PluginStorage 新增 read_config / write_config /
  async_write_config / delete_config,PluginConfigStore 去掉 key_prefix 与 _key,
  不再有任何字符串键经过;plugin_exists 存在性规则、空键过滤与 force 语义不变。
- PluginInstanceOper 补齐读取与异步写入原语 get_config_data /
  async_save_config_data,同步与异步共用同一份落库决策,避免两条路径对「值未变化
  不写」和「建行用哪个源插件」各自分叉。
- _PluginBase 的 get_config / update_config 直连 PluginInstanceOper,方法名、签名、
  返回与 docstring 不变;self.systemconfig 保留,插件读 SystemConfigKey 枚举值仍
  然合法,插件公开面没有扩大。

插件直接用 SystemConfig 存取自身配置不予支持。它本就不是封装过的插件配置通道,
为它设计兼容或加守卫拦截,等于再把「插件」塞回通用存储里——那正是本次要拆掉的东
西。拆开后 plugin.<ID> 只是一个与插件无关的普通 systemconfig 键:迁移已经把旧的
plugin.* 行搬进插件实例表,读它拿到 None,写它会新增一条谁也不读的 systemconfig
记录,插件配置通道既不读也不写它,两边不会再有任何交叉。

代价是读取从内存快照变成一次按实例 ID 的主键查询。调用点都不在热路径上:宿主侧
只有插件启动/重载、配置表单接口与分身创建三处,插件侧 get_config() 惯例在
init_plugin 里取一次。写入侧没有变化,本来就是一次事务。

回归防线:新增用例断言 SystemConfigOper 对 plugin. 前缀没有任何特殊行为——写进去
就是一行普通 systemconfig 记录,读出来就是刚写的值,不会被路由到插件实例表。

兼容层零改动,三个 alembic 迁移一行未改。
2026-09-13 01:41:57 -04:00

280 lines
10 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.

"""插件配置落在插件实例表上的存放与读写测试。"""
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.models.systemconfig import SystemConfig
from app.db.oper.plugininstance import PluginInstanceOper
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import SessionFactory
from app.db.uow import run_sync_transaction
from app.runtime.extensions.plugin.database import PluginDatabase
from app.runtime.extensions.plugin.storage import PluginConfigStore, PluginStorage
from app.startup.initializers.plugins import (
_async_write_plugin_config,
_delete_plugin_config,
_read_plugin_config,
_write_plugin_config,
)
PLUGIN_ID = "PytestPluginConfigProbe"
CLONE_ID = f"{PLUGIN_ID}work"
_INSTANCE_IDS = (PLUGIN_ID, CLONE_ID)
@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)
@pytest.fixture(name="config_store")
def fixture_config_store() -> PluginConfigStore:
"""装配一个直连插件实例表的配置存储,与启动组合根使用同一组端口实现。"""
storage = PluginStorage(
read_config=_read_plugin_config,
write_config=_write_plugin_config,
async_write_config=_async_write_plugin_config,
delete_config=_delete_plugin_config,
)
return PluginConfigStore(
storage=lambda: storage,
database=PluginDatabase,
plugin_exists=lambda _plugin_id: True,
)
def _row_counts(instance_id: str) -> tuple[int, int]:
"""返回该实例分别在实例表与系统设置表中的行数。"""
session = SessionFactory()
try:
in_instance_table = PluginInstance.get_by_instance_id(session, instance_id) is not None
in_system_table = SystemConfig.get_by_key(session, f"plugin.{instance_id}") is not None
return int(in_instance_table), int(in_system_table)
finally:
session.close()
def test_plugin_config_is_written_to_the_instance_table(config_store: PluginConfigStore):
"""插件配置落进 plugininstance 表,不再占用主程序设置表的行。"""
assert config_store.write(PLUGIN_ID, {"enable": True, "token": "probe"}) is True
assert _row_counts(PLUGIN_ID) == (1, 0)
def test_plugin_config_reads_back_through_the_config_store(config_store: PluginConfigStore):
"""写进去的配置从同一个配置存储原样读得回来。"""
config_store.write(PLUGIN_ID, {"enable": False, "token": "probe"})
assert config_store.read(PLUGIN_ID) == {"enable": False, "token": "probe"}
def test_reading_config_filters_out_historical_empty_keys(config_store: PluginConfigStore):
"""历史遗留的空键不得进入插件拿到的配置字典。"""
PluginInstanceOper().save_config_data(
instance_id=PLUGIN_ID,
source_plugin_id=PLUGIN_ID,
config_data={"": "legacy", "enable": True},
)
assert config_store.read(PLUGIN_ID) == {"enable": True}
def test_writing_config_for_an_unknown_plugin_is_refused_without_force():
"""未注册插件的配置写入默认拒绝,这是既有的存在性规则。"""
storage = PluginStorage(
read_config=_read_plugin_config,
write_config=_write_plugin_config,
delete_config=_delete_plugin_config,
)
store = PluginConfigStore(
storage=lambda: storage,
database=PluginDatabase,
plugin_exists=lambda _plugin_id: False,
)
assert store.write(PLUGIN_ID, {"enable": True}) is False
assert store.read(PLUGIN_ID) == {}
assert store.delete(PLUGIN_ID) is False
assert _row_counts(PLUGIN_ID) == (0, 0)
assert store.write(PLUGIN_ID, {"enable": True}, force=True) is True
assert store.delete(PLUGIN_ID, force=True) is True
@pytest.mark.asyncio
async def test_async_config_write_lands_on_the_same_instance_row(
config_store: PluginConfigStore,
):
"""异步写入与同步写入落在同一行,并遵循同一套建行与去重规则。"""
assert await config_store.async_write(PLUGIN_ID, {"enable": True}) is True
assert _row_counts(PLUGIN_ID) == (1, 0)
assert config_store.read(PLUGIN_ID) == {"enable": True}
# 同一份值再写一次不产生新行,也不改写归属
assert await config_store.async_write(PLUGIN_ID, {"enable": True}) is True
record = PluginInstanceOper().get(PLUGIN_ID)
assert record is not None
assert record.source_plugin_id == PLUGIN_ID
def test_deleting_config_of_a_bare_host_reclaims_the_empty_row(
config_store: PluginConfigStore,
):
"""只承载一份配置的本体行在配置删掉后就是空壳,一并回收。"""
config_store.write(PLUGIN_ID, {"enable": True})
assert _row_counts(PLUGIN_ID) == (1, 0)
assert config_store.delete(PLUGIN_ID) is True
assert _row_counts(PLUGIN_ID) == (0, 0)
assert config_store.read(PLUGIN_ID) == {}
def test_deleting_config_keeps_the_instance_that_still_carries_settings(
config_store: PluginConfigStore,
):
"""删一份配置不得把实例本身删掉:同一行还带着展示信息等设置。
配置与实例身份合表之后,删配置若照旧删行,「删一份配置」就会静默变成
「删掉这个实例」,展示信息一并消失。
"""
instances = PluginInstanceOper()
instances.save(
instance_id=PLUGIN_ID,
source_plugin_id=PLUGIN_ID,
plugin_name="本体展示名",
)
config_store.write(PLUGIN_ID, {"enable": True})
config_store.delete(PLUGIN_ID)
survivor = instances.get(PLUGIN_ID)
assert survivor is not None
assert survivor.plugin_name == "本体展示名"
assert survivor.config_data is None
def test_deleting_a_clone_config_never_removes_the_clone_itself(
config_store: PluginConfigStore,
):
"""分身的存在由它那一行表达,清空配置不等于删掉这个分身。"""
instances = PluginInstanceOper()
instances.save(instance_id=CLONE_ID, source_plugin_id=PLUGIN_ID)
config_store.write(CLONE_ID, {"token": "probe"})
config_store.delete(CLONE_ID)
survivor = instances.get(CLONE_ID)
assert survivor is not None
assert survivor.config_data is None
def test_clone_config_records_its_source_plugin_so_instances_can_be_listed(
config_store: PluginConfigStore,
):
"""分身配置要记下它属于哪个源插件,否则只能靠字符串前缀去猜归属。
扁平键时代配置行只按实例 ID 命名,想列出「某插件的全部实例配置」无从下手。
"""
instances = PluginInstanceOper()
instances.save(instance_id=CLONE_ID, source_plugin_id=PLUGIN_ID)
config_store.write(PLUGIN_ID, {"host": True})
config_store.write(CLONE_ID, {"clone": True})
owned = instances.list_by_source(PLUGIN_ID)
assert {record.instance_id for record in owned} == {PLUGIN_ID, CLONE_ID}
# 本体自身的 source_plugin_id 等于 instance_id分身则指向源插件
clone_record = instances.get(CLONE_ID)
assert clone_record.source_plugin_id == PLUGIN_ID
assert clone_record.is_host is False
def test_writing_config_does_not_clobber_the_display_fields_on_the_same_row(
config_store: PluginConfigStore,
):
"""插件自己存配置不得抹掉宿主写在同一行上的展示信息。"""
instances = PluginInstanceOper()
instances.save(
instance_id=CLONE_ID,
source_plugin_id=PLUGIN_ID,
plugin_name="分身甲",
)
config_store.write(CLONE_ID, {"enable": True})
record = instances.get(CLONE_ID)
assert record.plugin_name == "分身甲"
assert record.config_data == {"enable": True}
def test_descriptor_save_port_keeps_the_config_already_on_the_row(
config_store: PluginConfigStore,
):
"""组合根的实例落盘端口只写描述符各列,不得把同一行上的配置抹成空。
描述符视图里根本没有业务参数,若把它整份写回,用户刚存的配置会在下一次
改名或重载时静默消失。
"""
from app.schemas.plugin import PluginInstance as PluginInstanceSchema
from app.startup.initializers.plugins import _save_plugin_instance_record
config_store.write(CLONE_ID, {"token": "keep"})
_save_plugin_instance_record(
PluginInstanceSchema(
instance_id=CLONE_ID,
source_plugin_id=CLONE_ID,
plugin_name="分身甲",
)
)
record = PluginInstanceOper().get(CLONE_ID)
assert record is not None
assert record.plugin_name == "分身甲"
assert record.config_data == {"token": "keep"}
def test_saving_an_instance_under_a_different_source_is_rejected():
"""实例归属是身份的一半,改写它等于偷换整行,持久化层直接拒绝。"""
instances = PluginInstanceOper()
instances.save(instance_id=CLONE_ID, source_plugin_id=PLUGIN_ID)
with pytest.raises(ValueError):
instances.save(instance_id=CLONE_ID, source_plugin_id="PytestOtherSource")
def test_system_config_treats_a_plugin_prefixed_key_like_any_other_key():
"""SystemConfigOper 对 `plugin.` 前缀不得有任何特例行为。
插件配置已经完整拆到插件实例表,系统设置存储不该再知道插件的存在:规范方法
按键值分支等于在通用存储里塞进一条反向依赖,既让读写路径依插件而异,也让
「这个键存在哪张表」变得只有读代码才说得清。本用例是那条约束的护栏。
"""
oper = SystemConfigOper()
key = f"plugin.{PLUGIN_ID}"
try:
oper.set(key, {"plain": "systemconfig row"})
session = SessionFactory()
try:
assert SystemConfig.get_by_key(session, key) is not None
assert PluginInstance.get_by_instance_id(session, PLUGIN_ID) is None
finally:
session.close()
assert oper.get(key) == {"plain": "systemconfig row"}
finally:
oper.delete(key)