mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-29 03:56:43 +08:00
feat(plugin): 建立可信来源准入与安装恢复 (#6462)
This commit is contained in:
@@ -314,6 +314,7 @@ def configure_plugin_system_services():
|
||||
if flag else []
|
||||
),
|
||||
frozen=lambda: False,
|
||||
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
|
||||
))
|
||||
from app.agent.skills.registry import SkillHelper
|
||||
from app.agent.llm.gateway import register_llm_provider_runtime
|
||||
|
||||
+84
-14
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6706,
|
||||
"edge_sha256": "dc1249bc5f0ae05ec11c680236cf4dd389258cf883fbed66ed57b53875f9ef0e",
|
||||
"edge_count": 6767,
|
||||
"edge_sha256": "659804b4d1c0f3ff4d96e8c9a059df0afb74c91f368a73188c740558114efa61",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -157,6 +157,8 @@
|
||||
"app.adapters.system.plugin.package -> app.adapters",
|
||||
"app.adapters.system.plugin.package -> app.adapters.external",
|
||||
"app.adapters.system.plugin.package -> app.adapters.external.market",
|
||||
"app.adapters.system.plugin.package -> app.adapters.system",
|
||||
"app.adapters.system.plugin.package -> app.adapters.system.host",
|
||||
"app.adapters.system.plugin.package -> app.runtime",
|
||||
"app.adapters.system.plugin.package -> app.runtime.execution",
|
||||
"app.adapters.system.plugin.package -> app.runtime.log",
|
||||
@@ -590,10 +592,6 @@
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.external",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.market",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.external.server",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.system",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.adapters.system.plugin.package",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.agent",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.agent.tools",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.agent.tools.base",
|
||||
@@ -602,7 +600,7 @@
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.configuration",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.folders",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.install",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.gateway",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.routes",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.plugin.runtime",
|
||||
"app.agent.tools.impl._plugin_tool_utils -> app.application.scheduling",
|
||||
@@ -2100,9 +2098,6 @@
|
||||
"app.api.endpoints.plugin -> app.adapters.external",
|
||||
"app.api.endpoints.plugin -> app.adapters.external.market",
|
||||
"app.api.endpoints.plugin -> app.adapters.external.server",
|
||||
"app.api.endpoints.plugin -> app.adapters.system",
|
||||
"app.api.endpoints.plugin -> app.adapters.system.plugin",
|
||||
"app.api.endpoints.plugin -> app.adapters.system.plugin.package",
|
||||
"app.api.endpoints.plugin -> app.adapters.web",
|
||||
"app.api.endpoints.plugin -> app.adapters.web.security",
|
||||
"app.api.endpoints.plugin -> app.adapters.web.security.access",
|
||||
@@ -2119,13 +2114,13 @@
|
||||
"app.api.endpoints.plugin -> app.application.plugin",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.config",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.folders",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.install",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.gateway",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.routes",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.runtime",
|
||||
"app.api.endpoints.plugin -> app.application.plugin.transaction",
|
||||
"app.api.endpoints.plugin -> app.application.scheduling",
|
||||
"app.api.endpoints.plugin -> app.runtime",
|
||||
"app.api.endpoints.plugin -> app.runtime.cache",
|
||||
"app.api.endpoints.plugin -> app.runtime.execution",
|
||||
"app.api.endpoints.plugin -> app.runtime.extensions",
|
||||
"app.api.endpoints.plugin -> app.runtime.extensions.plugin",
|
||||
"app.api.endpoints.plugin -> app.runtime.extensions.plugin.contracts",
|
||||
@@ -2747,6 +2742,11 @@
|
||||
"app.application.notification -> app.schemas.types",
|
||||
"app.application.outbox -> app.schemas",
|
||||
"app.application.outbox -> app.schemas.types",
|
||||
"app.application.plugin.admission -> app.application",
|
||||
"app.application.plugin.admission -> app.application.plugin",
|
||||
"app.application.plugin.admission -> app.application.plugin.identity",
|
||||
"app.application.plugin.admission -> app.application.plugin.inventory",
|
||||
"app.application.plugin.admission -> app.application.plugin.source",
|
||||
"app.application.plugin.config -> app.schemas",
|
||||
"app.application.plugin.config -> app.schemas.exception",
|
||||
"app.application.plugin.folders -> app.application",
|
||||
@@ -2755,16 +2755,52 @@
|
||||
"app.application.plugin.folders -> app.runtime.log",
|
||||
"app.application.plugin.folders -> app.schemas",
|
||||
"app.application.plugin.folders -> app.schemas.types",
|
||||
"app.application.plugin.gateway -> app.application",
|
||||
"app.application.plugin.gateway -> app.application.plugin",
|
||||
"app.application.plugin.gateway -> app.application.plugin.admission",
|
||||
"app.application.plugin.gateway -> app.application.plugin.identity",
|
||||
"app.application.plugin.gateway -> app.application.plugin.install",
|
||||
"app.application.plugin.gateway -> app.application.plugin.inventory",
|
||||
"app.application.plugin.gateway -> app.application.plugin.lifecycle",
|
||||
"app.application.plugin.gateway -> app.application.plugin.source",
|
||||
"app.application.plugin.identity_migration -> app.application",
|
||||
"app.application.plugin.identity_migration -> app.application.plugin",
|
||||
"app.application.plugin.identity_migration -> app.application.plugin.identity",
|
||||
"app.application.plugin.identity_migration -> app.application.plugin.source",
|
||||
"app.application.plugin.identity_migration -> app.runtime",
|
||||
"app.application.plugin.identity_migration -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.application",
|
||||
"app.application.plugin.install -> app.application.plugin",
|
||||
"app.application.plugin.install -> app.application.plugin.lifecycle",
|
||||
"app.application.plugin.install -> app.application.plugin.admission",
|
||||
"app.application.plugin.install -> app.application.plugin.identity",
|
||||
"app.application.plugin.install -> app.application.plugin.source",
|
||||
"app.application.plugin.install -> app.application.plugin.transaction",
|
||||
"app.application.plugin.install -> app.runtime",
|
||||
"app.application.plugin.install -> app.runtime.execution",
|
||||
"app.application.plugin.install -> app.runtime.log",
|
||||
"app.application.plugin.install -> app.schemas",
|
||||
"app.application.plugin.install -> app.schemas.exception",
|
||||
"app.application.plugin.inventory -> app.application",
|
||||
"app.application.plugin.inventory -> app.application.plugin",
|
||||
"app.application.plugin.inventory -> app.application.plugin.identity",
|
||||
"app.application.plugin.inventory -> app.application.plugin.source",
|
||||
"app.application.plugin.recovery -> app.application",
|
||||
"app.application.plugin.recovery -> app.application.plugin",
|
||||
"app.application.plugin.recovery -> app.application.plugin.install",
|
||||
"app.application.plugin.recovery -> app.application.plugin.transaction",
|
||||
"app.application.plugin.recovery -> app.runtime",
|
||||
"app.application.plugin.recovery -> app.runtime.log",
|
||||
"app.application.plugin.runtime -> app.schemas",
|
||||
"app.application.plugin.runtime -> app.schemas.types",
|
||||
"app.application.plugin.source -> app.application",
|
||||
"app.application.plugin.source -> app.application.plugin",
|
||||
"app.application.plugin.source -> app.application.plugin.identity",
|
||||
"app.application.plugin.source -> app.foundation",
|
||||
"app.application.plugin.source -> app.foundation.version",
|
||||
"app.application.plugin.transaction -> app.application",
|
||||
"app.application.plugin.transaction -> app.application.database",
|
||||
"app.application.plugin.transaction -> app.application.plugin",
|
||||
"app.application.plugin.transaction -> app.application.plugin.identity",
|
||||
"app.application.recognition -> app.application",
|
||||
"app.application.recognition -> app.application.configuration",
|
||||
"app.application.recognition -> app.schemas",
|
||||
@@ -3637,6 +3673,14 @@
|
||||
"app.db.adapters.pluginidentity -> app.db.oper",
|
||||
"app.db.adapters.pluginidentity -> app.db.oper.pluginidentity",
|
||||
"app.db.adapters.pluginidentity -> app.db.uow",
|
||||
"app.db.adapters.plugininstallation -> app.application",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin.identity",
|
||||
"app.db.adapters.plugininstallation -> app.application.plugin.transaction",
|
||||
"app.db.adapters.plugininstallation -> app.db",
|
||||
"app.db.adapters.plugininstallation -> app.db.models",
|
||||
"app.db.adapters.plugininstallation -> app.db.models.pluginidentity",
|
||||
"app.db.adapters.plugininstallation -> app.db.models.plugininstallation",
|
||||
"app.db.adapters.site -> app.db",
|
||||
"app.db.adapters.site -> app.db.oper",
|
||||
"app.db.adapters.site -> app.db.oper.site",
|
||||
@@ -3727,6 +3771,8 @@
|
||||
"app.db.models.plugindata -> app.db.base",
|
||||
"app.db.models.pluginidentity -> app.db",
|
||||
"app.db.models.pluginidentity -> app.db.base",
|
||||
"app.db.models.plugininstallation -> app.db",
|
||||
"app.db.models.plugininstallation -> app.db.base",
|
||||
"app.db.models.site -> app.db",
|
||||
"app.db.models.site -> app.db.base",
|
||||
"app.db.models.siteicon -> app.db",
|
||||
@@ -6344,6 +6390,7 @@
|
||||
"app.startup.initializers.modules -> app.application.outbox",
|
||||
"app.startup.initializers.modules -> app.application.plugin",
|
||||
"app.startup.initializers.modules -> app.application.plugin.runtime",
|
||||
"app.startup.initializers.modules -> app.application.plugin.transaction",
|
||||
"app.startup.initializers.modules -> app.application.security",
|
||||
"app.startup.initializers.modules -> app.application.security.auth",
|
||||
"app.startup.initializers.modules -> app.application.security.passkeys",
|
||||
@@ -6375,6 +6422,8 @@
|
||||
"app.startup.initializers.modules -> app.db.adapters.chain",
|
||||
"app.startup.initializers.modules -> app.db.adapters.download",
|
||||
"app.startup.initializers.modules -> app.db.adapters.outbox",
|
||||
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
|
||||
"app.startup.initializers.modules -> app.db.adapters.plugininstallation",
|
||||
"app.startup.initializers.modules -> app.db.adapters.site",
|
||||
"app.startup.initializers.modules -> app.db.adapters.subscription",
|
||||
"app.startup.initializers.modules -> app.db.adapters.transaction",
|
||||
@@ -6446,11 +6495,22 @@
|
||||
"app.startup.initializers.plugins -> app.adapters.system.plugin.manifest",
|
||||
"app.startup.initializers.plugins -> app.adapters.system.plugin.package",
|
||||
"app.startup.initializers.plugins -> app.application",
|
||||
"app.startup.initializers.plugins -> app.application.commands",
|
||||
"app.startup.initializers.plugins -> app.application.configuration",
|
||||
"app.startup.initializers.plugins -> app.application.plugin",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.catalog",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.data",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.gateway",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.identity",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.identity_migration",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.install",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.inventory",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.lifecycle",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.recovery",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.routes",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.runtime",
|
||||
"app.startup.initializers.plugins -> app.application.plugin.transaction",
|
||||
"app.startup.initializers.plugins -> app.application.scheduling",
|
||||
"app.startup.initializers.plugins -> app.application.site",
|
||||
"app.startup.initializers.plugins -> app.db",
|
||||
"app.startup.initializers.plugins -> app.db.oper",
|
||||
@@ -6498,6 +6558,7 @@
|
||||
"app.startup.lifecycle -> app.application",
|
||||
"app.startup.lifecycle -> app.application.plugin",
|
||||
"app.startup.lifecycle -> app.application.plugin.lifecycle",
|
||||
"app.startup.lifecycle -> app.application.plugin.recovery",
|
||||
"app.startup.lifecycle -> app.application.plugin.runtime",
|
||||
"app.startup.lifecycle -> app.chain",
|
||||
"app.startup.lifecycle -> app.chain.system",
|
||||
@@ -6723,7 +6784,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 824,
|
||||
"module_count": 833,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -7014,15 +7075,22 @@
|
||||
"app.application.notification",
|
||||
"app.application.outbox",
|
||||
"app.application.plugin",
|
||||
"app.application.plugin.admission",
|
||||
"app.application.plugin.catalog",
|
||||
"app.application.plugin.config",
|
||||
"app.application.plugin.data",
|
||||
"app.application.plugin.folders",
|
||||
"app.application.plugin.gateway",
|
||||
"app.application.plugin.identity",
|
||||
"app.application.plugin.identity_migration",
|
||||
"app.application.plugin.install",
|
||||
"app.application.plugin.inventory",
|
||||
"app.application.plugin.lifecycle",
|
||||
"app.application.plugin.recovery",
|
||||
"app.application.plugin.routes",
|
||||
"app.application.plugin.runtime",
|
||||
"app.application.plugin.source",
|
||||
"app.application.plugin.transaction",
|
||||
"app.application.recognition",
|
||||
"app.application.rss",
|
||||
"app.application.rules",
|
||||
@@ -7109,6 +7177,7 @@
|
||||
"app.db.adapters.download",
|
||||
"app.db.adapters.outbox",
|
||||
"app.db.adapters.pluginidentity",
|
||||
"app.db.adapters.plugininstallation",
|
||||
"app.db.adapters.site",
|
||||
"app.db.adapters.subscription",
|
||||
"app.db.adapters.transaction",
|
||||
@@ -7133,6 +7202,7 @@
|
||||
"app.db.models.passkey",
|
||||
"app.db.models.plugindata",
|
||||
"app.db.models.pluginidentity",
|
||||
"app.db.models.plugininstallation",
|
||||
"app.db.models.site",
|
||||
"app.db.models.siteicon",
|
||||
"app.db.models.sitestatistic",
|
||||
|
||||
+131
-131
@@ -1,41 +1,41 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"generated_at": "2026-08-24T23:06:26.288074+00:00",
|
||||
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
|
||||
"python": "3.14.3",
|
||||
"generated_at": "2026-08-25T23:30:44.529042+00:00",
|
||||
"platform": "macOS-26.4.1-arm64-arm-64bit-Mach-O",
|
||||
"python": "3.14.7",
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_app_module_count": 364,
|
||||
"max_ms": 904.069,
|
||||
"median_ms": 898.164,
|
||||
"min_ms": 896.39,
|
||||
"loaded_app_module_count": 378,
|
||||
"max_ms": 909.62,
|
||||
"median_ms": 908.975,
|
||||
"min_ms": 904.929,
|
||||
"samples_ms": [
|
||||
904.069,
|
||||
896.39,
|
||||
898.164
|
||||
909.62,
|
||||
904.929,
|
||||
908.975
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_app_module_count": 376,
|
||||
"max_ms": 923.165,
|
||||
"median_ms": 921.768,
|
||||
"min_ms": 921.249,
|
||||
"loaded_app_module_count": 390,
|
||||
"max_ms": 952.709,
|
||||
"median_ms": 934.785,
|
||||
"min_ms": 916.888,
|
||||
"samples_ms": [
|
||||
921.768,
|
||||
923.165,
|
||||
921.249
|
||||
952.709,
|
||||
934.785,
|
||||
916.888
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_app_module_count": 378,
|
||||
"max_ms": 1069.392,
|
||||
"median_ms": 1037.036,
|
||||
"min_ms": 1027.603,
|
||||
"loaded_app_module_count": 392,
|
||||
"max_ms": 947.928,
|
||||
"median_ms": 938.251,
|
||||
"min_ms": 932.597,
|
||||
"samples_ms": [
|
||||
1069.392,
|
||||
1037.036,
|
||||
1027.603
|
||||
938.251,
|
||||
932.597,
|
||||
947.928
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -47,56 +47,25 @@
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 25,
|
||||
"startup_ms": 0.645,
|
||||
"full_lifespan_ms": 1.493,
|
||||
"startup_ms": 0.581,
|
||||
"full_lifespan_ms": 1.397,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.071,
|
||||
"数据库准备": 0.043,
|
||||
"HTTP 基础能力": 0.029,
|
||||
"领域依赖装配": 0.029,
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.023,
|
||||
"路由": 0.022,
|
||||
"模块服务": 0.024,
|
||||
"插件备份恢复": 0.025,
|
||||
"插件": 0.021,
|
||||
"定时器": 0.025,
|
||||
"监控器": 0.021,
|
||||
"待处理整理回放": 0.027,
|
||||
"命令服务": 0.024,
|
||||
"工作流": 0.021,
|
||||
"插件同步与启动收尾": 0.021
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 1,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 25,
|
||||
"startup_ms": 0.644,
|
||||
"full_lifespan_ms": 1.462,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.076,
|
||||
"数据库准备": 0.04,
|
||||
"HTTP 基础能力": 0.03,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.025,
|
||||
"数据库连接预算": 0.023,
|
||||
"路由": 0.024,
|
||||
"后台任务登记器": 0.062,
|
||||
"数据库准备": 0.036,
|
||||
"HTTP 基础能力": 0.032,
|
||||
"领域依赖装配": 0.032,
|
||||
"数据库引擎预热": 0.028,
|
||||
"数据库连接预算": 0.026,
|
||||
"路由": 0.028,
|
||||
"模块服务": 0.023,
|
||||
"插件备份恢复": 0.024,
|
||||
"插件": 0.02,
|
||||
"定时器": 0.022,
|
||||
"插件": 0.024,
|
||||
"定时器": 0.025,
|
||||
"监控器": 0.024,
|
||||
"待处理整理回放": 0.02,
|
||||
"待处理整理回放": 0.025,
|
||||
"命令服务": 0.024,
|
||||
"工作流": 0.024,
|
||||
"插件同步与启动收尾": 0.023
|
||||
"工作流": 0.025,
|
||||
"插件同步与启动收尾": 0.025
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -109,25 +78,56 @@
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 25,
|
||||
"startup_ms": 0.637,
|
||||
"full_lifespan_ms": 1.493,
|
||||
"startup_ms": 0.596,
|
||||
"full_lifespan_ms": 1.431,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.077,
|
||||
"数据库准备": 0.038,
|
||||
"HTTP 基础能力": 0.03,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.024,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.025,
|
||||
"后台任务登记器": 0.062,
|
||||
"数据库准备": 0.036,
|
||||
"HTTP 基础能力": 0.033,
|
||||
"领域依赖装配": 0.036,
|
||||
"数据库引擎预热": 0.03,
|
||||
"数据库连接预算": 0.027,
|
||||
"路由": 0.029,
|
||||
"模块服务": 0.025,
|
||||
"插件备份恢复": 0.023,
|
||||
"插件": 0.02,
|
||||
"定时器": 0.024,
|
||||
"监控器": 0.022,
|
||||
"待处理整理回放": 0.022,
|
||||
"插件": 0.024,
|
||||
"定时器": 0.029,
|
||||
"监控器": 0.024,
|
||||
"待处理整理回放": 0.024,
|
||||
"命令服务": 0.024,
|
||||
"工作流": 0.02,
|
||||
"插件同步与启动收尾": 0.023
|
||||
"插件同步与启动收尾": 0.025
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 1,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 25,
|
||||
"startup_ms": 0.6,
|
||||
"full_lifespan_ms": 1.435,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.067,
|
||||
"数据库准备": 0.04,
|
||||
"HTTP 基础能力": 0.033,
|
||||
"领域依赖装配": 0.031,
|
||||
"数据库引擎预热": 0.031,
|
||||
"数据库连接预算": 0.028,
|
||||
"路由": 0.03,
|
||||
"模块服务": 0.024,
|
||||
"插件备份恢复": 0.026,
|
||||
"插件": 0.025,
|
||||
"定时器": 0.025,
|
||||
"监控器": 0.025,
|
||||
"待处理整理回放": 0.02,
|
||||
"命令服务": 0.025,
|
||||
"工作流": 0.023,
|
||||
"插件同步与启动收尾": 0.024
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -138,8 +138,8 @@
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.644,
|
||||
"median_full_lifespan_ms": 1.493,
|
||||
"median_startup_ms": 0.596,
|
||||
"median_full_lifespan_ms": 1.431,
|
||||
"enabled_component_count": 25,
|
||||
"enabled_components": [
|
||||
"后台任务登记器",
|
||||
@@ -174,42 +174,18 @@
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 13,
|
||||
"startup_ms": 0.478,
|
||||
"full_lifespan_ms": 0.888,
|
||||
"startup_ms": 0.427,
|
||||
"full_lifespan_ms": 0.896,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.072,
|
||||
"数据库准备": 0.042,
|
||||
"HTTP 基础能力": 0.031,
|
||||
"领域依赖装配": 0.033,
|
||||
"数据库引擎预热": 0.027,
|
||||
"数据库连接预算": 0.024,
|
||||
"路由": 0.023,
|
||||
"模块服务": 0.025,
|
||||
"插件同步与启动收尾": 0.025
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 1,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 13,
|
||||
"startup_ms": 0.497,
|
||||
"full_lifespan_ms": 0.913,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.078,
|
||||
"后台任务登记器": 0.067,
|
||||
"数据库准备": 0.038,
|
||||
"HTTP 基础能力": 0.03,
|
||||
"领域依赖装配": 0.032,
|
||||
"数据库引擎预热": 0.027,
|
||||
"HTTP 基础能力": 0.032,
|
||||
"领域依赖装配": 0.033,
|
||||
"数据库引擎预热": 0.029,
|
||||
"数据库连接预算": 0.026,
|
||||
"路由": 0.026,
|
||||
"模块服务": 0.025,
|
||||
"插件同步与启动收尾": 0.022
|
||||
"路由": 0.03,
|
||||
"模块服务": 0.03,
|
||||
"插件同步与启动收尾": 0.027
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -222,18 +198,42 @@
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 13,
|
||||
"startup_ms": 0.537,
|
||||
"full_lifespan_ms": 0.962,
|
||||
"startup_ms": 0.417,
|
||||
"full_lifespan_ms": 0.832,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.084,
|
||||
"数据库准备": 0.039,
|
||||
"HTTP 基础能力": 0.031,
|
||||
"领域依赖装配": 0.028,
|
||||
"数据库引擎预热": 0.025,
|
||||
"数据库连接预算": 0.024,
|
||||
"路由": 0.025,
|
||||
"模块服务": 0.026,
|
||||
"插件同步与启动收尾": 0.024
|
||||
"后台任务登记器": 0.067,
|
||||
"数据库准备": 0.035,
|
||||
"HTTP 基础能力": 0.032,
|
||||
"领域依赖装配": 0.031,
|
||||
"数据库引擎预热": 0.032,
|
||||
"数据库连接预算": 0.027,
|
||||
"路由": 0.027,
|
||||
"模块服务": 0.027,
|
||||
"插件同步与启动收尾": 0.026
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 1,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 13,
|
||||
"startup_ms": 0.409,
|
||||
"full_lifespan_ms": 0.822,
|
||||
"stage_ms": {
|
||||
"后台任务登记器": 0.067,
|
||||
"数据库准备": 0.034,
|
||||
"HTTP 基础能力": 0.03,
|
||||
"领域依赖装配": 0.029,
|
||||
"数据库引擎预热": 0.028,
|
||||
"数据库连接预算": 0.026,
|
||||
"路由": 0.03,
|
||||
"模块服务": 0.027,
|
||||
"插件同步与启动收尾": 0.022
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
@@ -244,8 +244,8 @@
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.497,
|
||||
"median_full_lifespan_ms": 0.913,
|
||||
"median_startup_ms": 0.417,
|
||||
"median_full_lifespan_ms": 0.832,
|
||||
"enabled_component_count": 13,
|
||||
"enabled_components": [
|
||||
"后台任务登记器",
|
||||
|
||||
@@ -10,7 +10,7 @@ from app.agent.tools.impl._plugin_tool_utils import (
|
||||
install_plugin_runtime,
|
||||
uninstall_plugin_runtime,
|
||||
)
|
||||
from app.agent.tools.impl.install_plugin import InstallPluginTool
|
||||
from app.agent.tools.impl.install_plugin import InstallPluginInput, InstallPluginTool
|
||||
from app.agent.tools.impl.query_installed_plugins import QueryInstalledPluginsTool
|
||||
from app.agent.tools.impl.query_market_plugins import QueryMarketPluginsTool
|
||||
from app.agent.tools.impl.query_plugin_config import QueryPluginConfigTool
|
||||
@@ -282,49 +282,84 @@ def test_install_plugin_installs_market_candidate() -> None:
|
||||
assert payload["success"]
|
||||
assert payload["plugin"]["id"] == "DemoPlugin"
|
||||
install_runtime.assert_awaited_once_with(
|
||||
"DemoPlugin", "https://example.com/market", force=False
|
||||
"DemoPlugin",
|
||||
None,
|
||||
force=False,
|
||||
explicit_source=False,
|
||||
)
|
||||
|
||||
|
||||
def test_install_plugin_runtime_reloads_in_threadpool() -> None:
|
||||
"""
|
||||
已存在插件刷新加载时会通过插件线程池执行重载。
|
||||
"""
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.get_plugin_ids.return_value = ["DemoPlugin"]
|
||||
plugin_helper = MagicMock()
|
||||
config_oper = MagicMock()
|
||||
config_oper.get.return_value = ["DemoPlugin"]
|
||||
calls = []
|
||||
|
||||
async def fake_run_agent_blocking(bucket, func, *args, **kwargs) -> None:
|
||||
calls.append((bucket, func, args, kwargs))
|
||||
return None
|
||||
def test_install_plugin_reports_source_conflict_before_retry() -> None:
|
||||
"""Agent 普通安装遇到多来源时返回候选,等待管理员明确选择。"""
|
||||
tool = InstallPluginTool(session_id="session-1", user_id="10001")
|
||||
candidate = _market_plugin("DemoPlugin", "Demo Plugin")
|
||||
source_candidates = [
|
||||
{
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_type": "official",
|
||||
"source_key": "github:jxxghp/moviepilot-plugins",
|
||||
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "1.0.0",
|
||||
},
|
||||
{
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_type": "third_party",
|
||||
"source_key": "github:example/plugins",
|
||||
"repo_url": "https://github.com/example/plugins",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "2.0.0",
|
||||
},
|
||||
]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.get_configured_system_config",
|
||||
return_value=config_oper,
|
||||
"app.agent.tools.impl.install_plugin.load_market_plugins",
|
||||
new=AsyncMock(return_value=[candidate]),
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.get_plugin_manager",
|
||||
return_value=plugin_manager,
|
||||
"app.agent.tools.impl.install_plugin.install_plugin_runtime",
|
||||
new=AsyncMock(return_value=(False, "未安装插件存在多个在线来源", False)),
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.PluginHelper",
|
||||
return_value=plugin_helper,
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.refresh_plugin_registrations",
|
||||
) as refresh_registrations,
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.MoviePilotServerHelper.async_install_plugin_reg",
|
||||
AsyncMock(return_value=True),
|
||||
) as install_reg,
|
||||
patch(
|
||||
"app.agent.tools.base.run_agent_blocking",
|
||||
side_effect=fake_run_agent_blocking,
|
||||
"app.agent.tools.impl.install_plugin.inspect_plugin_sources",
|
||||
new=AsyncMock(return_value={
|
||||
"selection_status": "conflict",
|
||||
"selection_reason": "未安装插件存在多个在线来源,不能静默选择",
|
||||
"inventory_complete": True,
|
||||
"candidates": source_candidates,
|
||||
}),
|
||||
),
|
||||
):
|
||||
result = asyncio.run(tool.run(plugin_id="DemoPlugin"))
|
||||
|
||||
payload = json.loads(result)
|
||||
assert payload["success"] is False
|
||||
assert payload["requires_explicit_source"] is True
|
||||
assert payload["source_candidates"] == source_candidates
|
||||
|
||||
|
||||
@pytest.mark.parametrize("repo_url", ["", " ", "local://DemoPlugin"])
|
||||
def test_install_plugin_rejects_invalid_explicit_source(repo_url: str) -> None:
|
||||
"""Agent 不能用空值或本地标识伪造管理员在线选源。"""
|
||||
with pytest.raises(ValueError):
|
||||
InstallPluginInput(plugin_id="DemoPlugin", repo_url=repo_url)
|
||||
|
||||
|
||||
def test_install_plugin_runtime_uses_application_gateway() -> None:
|
||||
"""Agent 安装入口只能转发到统一的应用层安装 Gateway。"""
|
||||
gateway = MagicMock()
|
||||
gateway.install = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
success=True,
|
||||
message="插件已存在,已刷新加载",
|
||||
refreshed_only=True,
|
||||
)
|
||||
)
|
||||
|
||||
with patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.get_plugin_install_service",
|
||||
return_value=gateway,
|
||||
):
|
||||
success, message, refreshed_only = asyncio.run(
|
||||
install_plugin_runtime(
|
||||
@@ -337,18 +372,12 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None:
|
||||
assert success
|
||||
assert message == "插件已存在,已刷新加载"
|
||||
assert refreshed_only
|
||||
install_reg.assert_awaited_once_with(
|
||||
gateway.install.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://example.com/market",
|
||||
force=False,
|
||||
explicit_source=False,
|
||||
)
|
||||
assert len(calls) == 2
|
||||
assert calls[0][0] == "plugin"
|
||||
assert calls[0][2] == (plugin_manager.reload_plugin_tree, "DemoPlugin")
|
||||
assert calls[0][3] == {}
|
||||
assert calls[1][0] == "plugin"
|
||||
assert calls[1][1] == refresh_registrations
|
||||
assert calls[1][2] == ("DemoPlugin",)
|
||||
assert calls[1][3] == {}
|
||||
|
||||
|
||||
def test_uninstall_plugin_uninstalls_installed_candidate() -> None:
|
||||
|
||||
@@ -45,6 +45,13 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
|
||||
):
|
||||
monkeypatch.setattr(lifecycle, name, MagicMock())
|
||||
monkeypatch.setattr(lifecycle, "configure_plugin_services", MagicMock())
|
||||
plugin_recovery = MagicMock()
|
||||
plugin_recovery.replay = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"get_plugin_installation_recovery",
|
||||
MagicMock(return_value=plugin_recovery),
|
||||
)
|
||||
monkeypatch.setattr(lifecycle, "init_modules", AsyncMock())
|
||||
|
||||
# 启动期的引擎预热与额度核算也要打桩。不打的话这些用例会走真实的引擎创建,在测试
|
||||
@@ -442,6 +449,9 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch):
|
||||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||||
order = []
|
||||
lifecycle.configure_plugin_services.side_effect = lambda: order.append("configure")
|
||||
lifecycle.get_plugin_installation_recovery.return_value.replay.side_effect = (
|
||||
lambda: order.append("replay")
|
||||
)
|
||||
lifecycle.SystemChain.return_value.restore_plugins.side_effect = (
|
||||
lambda: order.append("restore")
|
||||
)
|
||||
@@ -452,7 +462,7 @@ def test_lifespan_configures_plugin_services_before_restore(monkeypatch):
|
||||
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
assert order == ["configure", "restore"]
|
||||
assert order == ["configure", "replay", "restore"]
|
||||
_assert_completed_once(shutdown_steps["close_http"])
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""插件市场候选库存读取测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.identity import TrustedPluginSourceType
|
||||
from app.application.plugin.inventory import PluginCandidateInventoryReader
|
||||
from app.application.plugin.source import LocalCandidateReadStatus, MarketReadStatus
|
||||
|
||||
OFFICIAL_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
THIRD_PARTY_MARKET = "https://github.com/example/moviepilot-plugins"
|
||||
|
||||
|
||||
def test_load_reads_each_market_in_v3_v2_v1_order_and_keeps_all_facts() -> None:
|
||||
"""每个市场的三代索引都应有独立读取记录,且同 ID 候选不能被合并。"""
|
||||
calls: list[tuple[str, str | None, bool]] = []
|
||||
|
||||
def loader(market: str, package_version: str | None, force: bool):
|
||||
calls.append((market, package_version, force))
|
||||
return {
|
||||
"DemoPlugin": {
|
||||
"version": f"{package_version or '1'}.0.0",
|
||||
"v3": True,
|
||||
},
|
||||
}
|
||||
|
||||
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
|
||||
[OFFICIAL_MARKET, THIRD_PARTY_MARKET],
|
||||
force=True,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
(OFFICIAL_MARKET, "v3", True),
|
||||
(OFFICIAL_MARKET, "v2", True),
|
||||
(OFFICIAL_MARKET, None, True),
|
||||
(THIRD_PARTY_MARKET, "v3", True),
|
||||
(THIRD_PARTY_MARKET, "v2", True),
|
||||
(THIRD_PARTY_MARKET, None, True),
|
||||
]
|
||||
assert inventory.complete
|
||||
assert [(read.market, read.package_generation) for read in inventory.market_reads] == [
|
||||
(OFFICIAL_MARKET, "v3"),
|
||||
(OFFICIAL_MARKET, "v2"),
|
||||
(OFFICIAL_MARKET, "v1"),
|
||||
(THIRD_PARTY_MARKET, "v3"),
|
||||
(THIRD_PARTY_MARKET, "v2"),
|
||||
(THIRD_PARTY_MARKET, "v1"),
|
||||
]
|
||||
assert len(inventory.candidates_for("demoplugin")) == 6
|
||||
|
||||
|
||||
def test_only_v3_compatible_entries_are_candidates() -> None:
|
||||
"""V3 明确排除项以及 V1 未声明兼容项不能进入候选库存。"""
|
||||
def loader(_market: str, package_version: str | None, _force: bool):
|
||||
if package_version == "v3":
|
||||
return {
|
||||
"V3Plugin": {"version": "3.0.0"},
|
||||
"ExcludedPlugin": {"version": "3.0.0", "v3": False},
|
||||
}
|
||||
if package_version == "v2":
|
||||
return {
|
||||
"SharedPlugin": {"version": "2.0.0"},
|
||||
"ExcludedPlugin": {"version": "2.0.0", "v3": False},
|
||||
}
|
||||
return {
|
||||
"DeclaredV3": {"version": "1.0.0", "v3": True},
|
||||
"DeclaredV2": {"version": "1.0.0", "v2": True},
|
||||
"Undeclared": {"version": "1.0.0"},
|
||||
"ExcludedPlugin": {"version": "1.0.0", "v3": False, "v2": True},
|
||||
}
|
||||
|
||||
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
|
||||
[THIRD_PARTY_MARKET]
|
||||
)
|
||||
|
||||
assert {
|
||||
candidate.plugin_id
|
||||
for candidate in inventory.online_candidates
|
||||
} == {"V3Plugin", "SharedPlugin", "DeclaredV3", "DeclaredV2"}
|
||||
assert not inventory.candidates_for("ExcludedPlugin")
|
||||
assert not inventory.candidates_for("Undeclared")
|
||||
|
||||
|
||||
def test_official_source_is_classified_and_public_candidate_uses_plugin_version() -> None:
|
||||
"""官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。"""
|
||||
reader = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {"DemoPlugin": {"version": "3.1.0"}},
|
||||
)
|
||||
|
||||
candidate = reader.load([OFFICIAL_MARKET]).online_candidates[0]
|
||||
|
||||
assert candidate.source_key == "github:jxxghp/moviepilot-plugins"
|
||||
assert candidate.source_type is TrustedPluginSourceType.OFFICIAL
|
||||
assert candidate.plugin_version == "3.1.0"
|
||||
assert candidate.public_dict() == {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_key": "github:jxxghp/moviepilot-plugins",
|
||||
"source_type": "official",
|
||||
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "3.1.0",
|
||||
}
|
||||
|
||||
|
||||
def test_partial_generation_failure_blocks_tofu_but_keeps_successful_candidates() -> None:
|
||||
"""某一代读取失败时保留其他代候选,但库存不能用于第三方 TOFU。"""
|
||||
def loader(_market: str, package_version: str | None, _force: bool):
|
||||
if package_version == "v2":
|
||||
raise TimeoutError("timeout")
|
||||
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
|
||||
|
||||
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
|
||||
[THIRD_PARTY_MARKET]
|
||||
)
|
||||
|
||||
assert len(inventory.candidates_for("DemoPlugin")) == 2
|
||||
assert not inventory.complete
|
||||
assert not inventory.can_use_for_tofu
|
||||
assert inventory.read_for(THIRD_PARTY_MARKET, "v2") is not None
|
||||
assert inventory.read_for(THIRD_PARTY_MARKET, "v2").error
|
||||
|
||||
|
||||
def test_absent_generation_is_complete_without_creating_candidates() -> None:
|
||||
"""确定不存在的代际索引属于完整库存,不应被误判为网络失败。"""
|
||||
|
||||
def loader(_market: str, package_version: str | None, _force: bool):
|
||||
if package_version == "v2":
|
||||
return None
|
||||
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
|
||||
|
||||
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
|
||||
[THIRD_PARTY_MARKET]
|
||||
)
|
||||
absent = inventory.read_for(THIRD_PARTY_MARKET, "v2")
|
||||
|
||||
assert absent is not None
|
||||
assert absent.status is MarketReadStatus.ABSENT
|
||||
assert absent.candidates == ()
|
||||
assert inventory.complete
|
||||
assert inventory.can_use_for_tofu
|
||||
assert len(inventory.candidates_for("DemoPlugin")) == 2
|
||||
|
||||
|
||||
def test_empty_index_is_present_and_complete() -> None:
|
||||
"""真实存在但为空的索引与 absent 保持可观察差异。"""
|
||||
inventory = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {},
|
||||
).load([THIRD_PARTY_MARKET])
|
||||
|
||||
assert inventory.complete
|
||||
assert all(
|
||||
read.status is MarketReadStatus.PRESENT
|
||||
for read in inventory.market_reads
|
||||
)
|
||||
assert inventory.online_candidates == ()
|
||||
|
||||
|
||||
def test_loader_exception_blocks_tofu() -> None:
|
||||
"""Adapter 读取失败时必须阻止唯一第三方来源 TOFU。"""
|
||||
|
||||
def loader(_market: str, package_version: str | None, _force: bool):
|
||||
if package_version == "v2":
|
||||
raise TimeoutError("timeout")
|
||||
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
|
||||
|
||||
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
|
||||
[THIRD_PARTY_MARKET]
|
||||
)
|
||||
|
||||
assert not inventory.complete
|
||||
assert not inventory.can_use_for_tofu
|
||||
assert inventory.read_for(THIRD_PARTY_MARKET, "v2").status is MarketReadStatus.FAILED
|
||||
|
||||
|
||||
def test_local_scan_preserves_absent_present_and_failed_states() -> None:
|
||||
"""本地仓库扫描不能把未配置、空扫描和异常读取混为一谈。"""
|
||||
def market_loader(*_args):
|
||||
return {}
|
||||
|
||||
absent = PluginCandidateInventoryReader(market_loader=market_loader).load(
|
||||
[THIRD_PARTY_MARKET]
|
||||
)
|
||||
present = PluginCandidateInventoryReader(
|
||||
market_loader=market_loader,
|
||||
local_candidate_loader=lambda: {},
|
||||
).load([THIRD_PARTY_MARKET])
|
||||
|
||||
def failed_loader():
|
||||
raise OSError("local repository unavailable")
|
||||
|
||||
failed = PluginCandidateInventoryReader(
|
||||
market_loader=market_loader,
|
||||
local_candidate_loader=failed_loader,
|
||||
).load([THIRD_PARTY_MARKET])
|
||||
|
||||
assert absent.local_read.status is LocalCandidateReadStatus.ABSENT
|
||||
assert present.local_read.status is LocalCandidateReadStatus.PRESENT
|
||||
assert present.local_read.candidates == ()
|
||||
assert failed.local_read.status is LocalCandidateReadStatus.FAILED
|
||||
assert failed.local_read.error == "local repository unavailable"
|
||||
|
||||
|
||||
def test_local_candidates_never_expose_path_in_inventory_projection() -> None:
|
||||
"""本地候选可参与库存,但公共投影永不携带本地仓库路径。"""
|
||||
reader = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {},
|
||||
local_candidate_loader=lambda: {
|
||||
"LocalPlugin": {
|
||||
"version": "3.0.0",
|
||||
"package_version": "v3",
|
||||
"repo_url": "local://LocalPlugin?path=/private/local&version=v3",
|
||||
"path": "/private/local/plugins/LocalPlugin",
|
||||
"repo_path": "/private/local",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
inventory = reader.load([OFFICIAL_MARKET])
|
||||
public = inventory.public_dict()
|
||||
|
||||
assert inventory.local_candidates[0].plugin_id == "LocalPlugin"
|
||||
assert public["local_candidates"] == [{
|
||||
"plugin_id": "LocalPlugin",
|
||||
"source_type": "local",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "3.0.0",
|
||||
}]
|
||||
assert "/private/local" not in str(public)
|
||||
|
||||
|
||||
def test_invalid_local_candidate_does_not_abort_online_inventory() -> None:
|
||||
"""本地索引中的坏代际条目应被跳过,不能丢失在线库存。"""
|
||||
reader = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {
|
||||
"OnlinePlugin": {"version": "3.0.0"},
|
||||
},
|
||||
local_candidate_loader=lambda: {
|
||||
"BrokenLocal": {
|
||||
"version": "1.0.0",
|
||||
"package_version": "v9",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
inventory = reader.load([OFFICIAL_MARKET])
|
||||
|
||||
assert [candidate.plugin_id for candidate in inventory.online_candidates] == [
|
||||
"OnlinePlugin",
|
||||
"OnlinePlugin",
|
||||
]
|
||||
assert inventory.local_candidates == ()
|
||||
|
||||
|
||||
def test_invalid_market_is_recorded_for_each_generation_without_network_call() -> None:
|
||||
"""非法市场配置应形成三条失败事实,且不会调用市场读取端口。"""
|
||||
calls: list[object] = []
|
||||
|
||||
def read(*_args):
|
||||
calls.append(True)
|
||||
return {}
|
||||
|
||||
inventory = PluginCandidateInventoryReader(market_loader=read).load(
|
||||
["https://example.com/not-github"]
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
assert len(inventory.market_reads) == 3
|
||||
assert all(not read.succeeded for read in inventory.market_reads)
|
||||
assert not inventory.complete
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_loader_preserves_generation_facts() -> None:
|
||||
"""异步读取端口与同步端口拥有相同的市场代际快照合同。"""
|
||||
calls: list[str | None] = []
|
||||
|
||||
async def loader(_market: str, package_version: str | None, _force: bool):
|
||||
calls.append(package_version)
|
||||
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
|
||||
|
||||
reader = PluginCandidateInventoryReader(
|
||||
market_loader=lambda *_args: {},
|
||||
async_market_loader=loader,
|
||||
)
|
||||
inventory = await reader.async_load([THIRD_PARTY_MARKET])
|
||||
|
||||
assert calls == ["v3", "v2", None]
|
||||
assert inventory.complete
|
||||
assert [read.package_generation for read in inventory.market_reads] == [
|
||||
"v3", "v2", "v1"
|
||||
]
|
||||
@@ -0,0 +1,653 @@
|
||||
"""插件包安装的外部调用边界测试。"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock, call
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.adapters.external import market
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.agent.tools.impl import _plugin_tool_utils
|
||||
from app.api.endpoints import plugin as plugin_endpoint
|
||||
from app.runtime.config import global_vars
|
||||
from app.schemas.plugin import (
|
||||
PluginSourceChangeRequest,
|
||||
PluginSourceIdentity,
|
||||
PluginSourceInstallRequest,
|
||||
PluginSourceOptions,
|
||||
)
|
||||
from app.startup.initializers import plugins as plugins_initializer
|
||||
|
||||
REPO_URL = "https://github.com/example/moviepilot-plugins"
|
||||
|
||||
|
||||
def test_package_manager_sync_preserves_external_install_contract() -> None:
|
||||
"""同步包适配器必须调用包级入口,不能再次进入公开 Gateway。"""
|
||||
helper = Mock()
|
||||
helper._PluginHelper__install_package.return_value = (True, "installed")
|
||||
manager = PluginPackageManager(helper=helper)
|
||||
|
||||
result = manager.install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
package_version="v3",
|
||||
release_version="1.2.3",
|
||||
force_install=False,
|
||||
)
|
||||
|
||||
assert result == (True, "installed")
|
||||
helper._PluginHelper__install_package.assert_called_once_with(
|
||||
pid="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
package_version="v3",
|
||||
release_version="1.2.3",
|
||||
force_install=False,
|
||||
)
|
||||
helper.install.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_package_manager_async_preserves_external_install_contract() -> None:
|
||||
"""异步包适配器必须调用包级入口,不能再次进入公开 Gateway。"""
|
||||
helper = Mock()
|
||||
helper._PluginHelper__async_install_package = AsyncMock(return_value=(True, "installed"))
|
||||
manager = PluginPackageManager(helper=helper)
|
||||
|
||||
result = await manager.async_install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
package_version="v3",
|
||||
release_version="1.2.3",
|
||||
force_install=False,
|
||||
)
|
||||
|
||||
assert result == (True, "installed")
|
||||
helper._PluginHelper__async_install_package.assert_awaited_once_with(
|
||||
pid="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
package_version="v3",
|
||||
release_version="1.2.3",
|
||||
force_install=False,
|
||||
)
|
||||
helper.async_install.assert_not_called()
|
||||
|
||||
|
||||
def test_external_sync_helper_rejects_until_gateway_is_configured(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""外部同步入口在宿主未装配来源门禁时不得直接写入插件包。"""
|
||||
helper = PluginHelper()
|
||||
monkeypatch.setattr(
|
||||
market,
|
||||
"_plugin_install_gateway",
|
||||
market._unconfigured_plugin_install_gateway,
|
||||
)
|
||||
|
||||
success, message = helper.install(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"v3",
|
||||
"1.2.3",
|
||||
True,
|
||||
)
|
||||
assert success is False
|
||||
assert message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_async_helper_rejects_until_gateway_is_configured(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""外部异步入口在宿主未装配来源门禁时不得直接写入插件包。"""
|
||||
helper = PluginHelper()
|
||||
monkeypatch.setattr(
|
||||
market,
|
||||
"_async_plugin_install_gateway",
|
||||
market._unconfigured_async_plugin_install_gateway,
|
||||
)
|
||||
|
||||
success, message = await helper.async_install(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"v3",
|
||||
"1.2.3",
|
||||
True,
|
||||
)
|
||||
assert success is False
|
||||
assert message
|
||||
|
||||
|
||||
def test_external_sync_helper_uses_configured_gateway(monkeypatch) -> None:
|
||||
"""外部同步调用必须把所有参数交给宿主统一安装用例。"""
|
||||
gateway = Mock(return_value=(False, "source conflict"))
|
||||
monkeypatch.setattr(market, "_plugin_install_gateway", gateway)
|
||||
|
||||
result = PluginHelper().install("DemoPlugin", REPO_URL, "v3", "1.2.3", True)
|
||||
|
||||
assert result == (False, "source conflict")
|
||||
gateway.assert_called_once_with("DemoPlugin", REPO_URL, "v3", "1.2.3", True)
|
||||
|
||||
|
||||
def test_sync_gateway_returns_failure_when_runtime_loop_is_unavailable(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""主事件循环释放后,同步兼容入口应稳定返回失败结果。"""
|
||||
gateway = Mock()
|
||||
monkeypatch.setattr(global_vars, "CURRENT_EVENT_LOOP", None)
|
||||
|
||||
result = plugins_initializer._run_plugin_install_sync(
|
||||
gateway,
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
package_version="v3",
|
||||
release_version="1.2.3",
|
||||
force=False,
|
||||
local_sync=False,
|
||||
explicit_source=True,
|
||||
)
|
||||
|
||||
assert result == (False, "插件安装服务当前不可用")
|
||||
gateway.install.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_async_helper_uses_configured_gateway(monkeypatch) -> None:
|
||||
"""外部异步调用必须把所有参数交给宿主统一安装用例。"""
|
||||
|
||||
async def gateway(*args):
|
||||
"""返回统一 Gateway 的结果。"""
|
||||
seen.append(args)
|
||||
return False, "source conflict"
|
||||
|
||||
seen = []
|
||||
monkeypatch.setattr(market, "_async_plugin_install_gateway", gateway)
|
||||
|
||||
result = await PluginHelper().async_install(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"v3",
|
||||
"1.2.3",
|
||||
True,
|
||||
)
|
||||
|
||||
assert result == (False, "source conflict")
|
||||
assert seen == [("DemoPlugin", REPO_URL, "v3", "1.2.3", True)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_async_helper_preserves_failure_tuple_on_gateway_error(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""公开异步 Helper 在持久化等内部异常下仍返回兼容二元组。"""
|
||||
gateway = Mock()
|
||||
gateway.install = AsyncMock(side_effect=RuntimeError("persistence unavailable"))
|
||||
|
||||
async def install(*args):
|
||||
"""按组合根的真实参数映射进入公开异步兼容包装层。"""
|
||||
plugin_id, repo_url, package_version, release_version, force = args
|
||||
return await plugins_initializer._run_plugin_install_async(
|
||||
gateway,
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version=package_version,
|
||||
release_version=release_version,
|
||||
force=force,
|
||||
local_sync=False,
|
||||
explicit_source=bool(repo_url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(market, "_async_plugin_install_gateway", install)
|
||||
|
||||
result = await PluginHelper().async_install(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"v3",
|
||||
"1.2.3",
|
||||
True,
|
||||
)
|
||||
|
||||
assert result == (False, "persistence unavailable")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_install_does_not_treat_repo_url_as_explicit_source(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""旧 GET 安装入口不能把兼容参数误当成管理员明确选源。"""
|
||||
gateway = Mock()
|
||||
gateway.install = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message="")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_endpoint,
|
||||
"get_plugin_install_service",
|
||||
lambda: gateway,
|
||||
)
|
||||
|
||||
result = await plugin_endpoint.install(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"1.2.3",
|
||||
False,
|
||||
None,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
gateway.install.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=None,
|
||||
release_version="1.2.3",
|
||||
force=False,
|
||||
explicit_source=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_explicit_source_install_uses_explicit_gateway_mode(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""专用来源安装入口必须把管理员选择传给统一 Gateway。"""
|
||||
gateway = Mock()
|
||||
gateway.install = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message="")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_endpoint,
|
||||
"get_plugin_install_service",
|
||||
lambda: gateway,
|
||||
)
|
||||
|
||||
result = await plugin_endpoint.install_plugin_from_source(
|
||||
"DemoPlugin",
|
||||
PluginSourceInstallRequest(
|
||||
repo_url=REPO_URL,
|
||||
release_version="1.2.3",
|
||||
force=True,
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
gateway.install.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
release_version="1.2.3",
|
||||
force=True,
|
||||
explicit_source=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_source_change_requires_revision_and_explicit_gateway_mode(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""管理员换源入口必须把目标仓库和精确 revision 交给统一 Gateway。"""
|
||||
gateway = Mock()
|
||||
gateway.install = AsyncMock(
|
||||
return_value=SimpleNamespace(success=True, message="")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugin_endpoint,
|
||||
"get_plugin_install_service",
|
||||
lambda: gateway,
|
||||
)
|
||||
|
||||
result = await plugin_endpoint.change_plugin_source(
|
||||
"DemoPlugin",
|
||||
PluginSourceChangeRequest(
|
||||
repo_url=REPO_URL,
|
||||
expected_revision=7,
|
||||
release_version="1.2.3",
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
gateway.install.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
release_version="1.2.3",
|
||||
force=True,
|
||||
explicit_source=True,
|
||||
source_change=True,
|
||||
expected_revision=7,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_source_identity_returns_current_cas_evidence(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""来源查询只公开确认和显式换源所需的最小身份字段。"""
|
||||
identity = SimpleNamespace(
|
||||
plugin_id="DemoPlugin",
|
||||
trusted_source_type=SimpleNamespace(value="official"),
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=SimpleNamespace(value="official_default"),
|
||||
payload_source_type=SimpleNamespace(value="local"),
|
||||
payload_source_key=None,
|
||||
revision=7,
|
||||
)
|
||||
persistence = Mock()
|
||||
persistence.get_identity = AsyncMock(return_value=identity)
|
||||
monkeypatch.setattr(
|
||||
plugin_endpoint,
|
||||
"get_plugin_persistence",
|
||||
lambda: persistence,
|
||||
)
|
||||
|
||||
result = await plugin_endpoint.get_plugin_source_identity(
|
||||
"DemoPlugin",
|
||||
None,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert isinstance(result.data, PluginSourceIdentity)
|
||||
assert result.data.plugin_id == "DemoPlugin"
|
||||
assert result.data.trusted_source_key == "github:jxxghp/moviepilot-plugins"
|
||||
assert result.data.payload_source_type == "local"
|
||||
assert result.data.revision == 7
|
||||
|
||||
|
||||
def test_source_change_schema_rejects_invalid_revision_and_blank_repo() -> None:
|
||||
"""显式换源请求在进入业务层前拒绝无来源或无效 revision。"""
|
||||
with pytest.raises(ValidationError):
|
||||
PluginSourceChangeRequest(repo_url=" ", expected_revision=1)
|
||||
with pytest.raises(ValidationError):
|
||||
PluginSourceChangeRequest(repo_url=REPO_URL, expected_revision=0)
|
||||
with pytest.raises(ValidationError):
|
||||
PluginSourceChangeRequest(
|
||||
repo_url="local://DemoPlugin",
|
||||
expected_revision=1,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_source_options_return_sanitized_candidates(monkeypatch) -> None:
|
||||
"""来源候选接口保留在线选择信息,但本地候选不公开路径。"""
|
||||
identity = SimpleNamespace(
|
||||
plugin_id="DemoPlugin",
|
||||
trusted_source_type=SimpleNamespace(value="official"),
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=SimpleNamespace(value="official_default"),
|
||||
payload_source_type=SimpleNamespace(value="local"),
|
||||
payload_source_key=None,
|
||||
revision=7,
|
||||
)
|
||||
inspection = SimpleNamespace(
|
||||
plugin_id="DemoPlugin",
|
||||
inventory_complete=True,
|
||||
identity=identity,
|
||||
selection=SimpleNamespace(
|
||||
status=SimpleNamespace(value="conflict"),
|
||||
reason="未安装插件存在多个在线来源,不能静默选择",
|
||||
),
|
||||
online_candidates=(
|
||||
SimpleNamespace(
|
||||
public_dict=lambda: {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_type": "official",
|
||||
"source_key": "github:jxxghp/moviepilot-plugins",
|
||||
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "1.0.0",
|
||||
}
|
||||
),
|
||||
),
|
||||
local_candidate=SimpleNamespace(
|
||||
public_dict=lambda: {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_type": "local",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "2.0.0-dev",
|
||||
}
|
||||
),
|
||||
)
|
||||
gateway = Mock()
|
||||
gateway.inspect_source = AsyncMock(return_value=inspection)
|
||||
monkeypatch.setattr(
|
||||
plugin_endpoint,
|
||||
"get_plugin_install_service",
|
||||
lambda: gateway,
|
||||
)
|
||||
|
||||
result = await plugin_endpoint.get_plugin_source_options(
|
||||
"DemoPlugin",
|
||||
None,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert isinstance(result.data, PluginSourceOptions)
|
||||
assert result.data.identity is not None
|
||||
assert result.data.identity.revision == 7
|
||||
assert [candidate.source_type for candidate in result.data.candidates] == [
|
||||
"official",
|
||||
"local",
|
||||
]
|
||||
assert result.data.candidates[1].repo_url is None
|
||||
assert "/private/" not in result.model_dump_json()
|
||||
|
||||
|
||||
def test_source_api_openapi_uses_structured_contracts() -> None:
|
||||
"""来源查询、初始选源和换源 API 必须公开稳定结构模型。"""
|
||||
app = FastAPI()
|
||||
app.include_router(plugin_endpoint.router, prefix="/api/v1/plugin")
|
||||
|
||||
paths = app.openapi()["paths"]
|
||||
change_operation = paths["/api/v1/plugin/source/{plugin_id}"]["post"]
|
||||
install_operation = paths["/api/v1/plugin/source/{plugin_id}/install"]["post"]
|
||||
options_operation = paths["/api/v1/plugin/source/{plugin_id}/options"]["get"]
|
||||
|
||||
change_schema = change_operation["requestBody"]["content"]["application/json"]["schema"]
|
||||
install_schema = install_operation["requestBody"]["content"]["application/json"]["schema"]
|
||||
options_schema = options_operation["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
|
||||
assert change_schema["$ref"].endswith("/PluginSourceChangeRequest")
|
||||
assert install_schema["$ref"].endswith("/PluginSourceInstallRequest")
|
||||
assert options_schema["$ref"].endswith("/Response_PluginSourceOptions_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_install_uses_application_gateway(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Agent 安装入口只能转发到唯一 Application Gateway。"""
|
||||
gateway = Mock()
|
||||
gateway.install = AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
success=True,
|
||||
message="installed",
|
||||
refreshed_only=False,
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_plugin_tool_utils,
|
||||
"get_plugin_install_service",
|
||||
lambda: gateway,
|
||||
)
|
||||
|
||||
result = await _plugin_tool_utils.install_plugin_runtime(
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
force=False,
|
||||
)
|
||||
|
||||
assert result == (True, "installed", False)
|
||||
gateway.install.assert_awaited_once_with(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
force=False,
|
||||
explicit_source=False,
|
||||
)
|
||||
|
||||
|
||||
def test_startup_composition_configures_external_helper_gateway(monkeypatch) -> None:
|
||||
"""启动组合根必须向 Application 与公开 Helper 发布同一 Gateway。"""
|
||||
helper = Mock()
|
||||
gateway_calls = []
|
||||
application_calls = []
|
||||
gateway = Mock()
|
||||
sync_runner = Mock(return_value=(True, "installed"))
|
||||
async_runner = AsyncMock(return_value=(True, "installed"))
|
||||
|
||||
monkeypatch.setattr(plugins_initializer, "PluginHelper", lambda: helper)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginMarketClient",
|
||||
lambda _helper: Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginPackageManager",
|
||||
lambda _helper: Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginCandidateInventoryReader",
|
||||
lambda **_kwargs: Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginInstallCommand",
|
||||
lambda **_kwargs: Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginInstallGateway",
|
||||
lambda **_kwargs: gateway,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginInstallationRecoveryService",
|
||||
lambda **_kwargs: Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginIdentityMigrationService",
|
||||
lambda **_kwargs: Mock(),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "get_plugin_manager", Mock())
|
||||
monkeypatch.setattr(plugins_initializer, "get_plugin_persistence", Mock())
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"configure_plugin_install_service",
|
||||
application_calls.append,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"configure_plugin_installation_recovery",
|
||||
Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"configure_plugin_identity_migration",
|
||||
Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"_run_plugin_install_sync",
|
||||
sync_runner,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"_run_plugin_install_async",
|
||||
async_runner,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"PluginDependencyInstaller",
|
||||
lambda *_args, **_kwargs: Mock(),
|
||||
)
|
||||
for name in (
|
||||
"configure_plugin_legacy_import_services",
|
||||
"configure_plugin_resource_import_preparer",
|
||||
"configure_site_auth_level_provider",
|
||||
"configure_installed_plugins_provider",
|
||||
"configure_plugin_catalog_factory",
|
||||
"configure_plugin_route_refresher",
|
||||
"configure_plugin_system",
|
||||
"configure_plugin_storage",
|
||||
):
|
||||
monkeypatch.setattr(plugins_initializer, name, Mock())
|
||||
|
||||
def configure_gateway(**kwargs) -> None:
|
||||
"""记录组合根提供给外部 Helper 的同步/异步端口。"""
|
||||
gateway_calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"configure_plugin_install_gateway",
|
||||
configure_gateway,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
plugins_initializer.configure_plugin_services()
|
||||
|
||||
assert application_calls == [gateway]
|
||||
assert len(gateway_calls) == 1
|
||||
assert callable(gateway_calls[0]["install"])
|
||||
assert callable(gateway_calls[0]["async_install"])
|
||||
|
||||
assert gateway_calls[0]["install"](
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"v3",
|
||||
"1.2.3",
|
||||
False,
|
||||
) == (True, "installed")
|
||||
assert asyncio.run(
|
||||
gateway_calls[0]["async_install"](
|
||||
"DemoPlugin",
|
||||
REPO_URL,
|
||||
"v3",
|
||||
"1.2.3",
|
||||
False,
|
||||
)
|
||||
) == (True, "installed")
|
||||
local_repo_url = "local://DemoPlugin?path=/private/plugins&version=v3"
|
||||
assert gateway_calls[0]["install"](
|
||||
"DemoPlugin",
|
||||
local_repo_url,
|
||||
"v3",
|
||||
None,
|
||||
True,
|
||||
) == (True, "installed")
|
||||
assert asyncio.run(
|
||||
gateway_calls[0]["async_install"](
|
||||
"DemoPlugin",
|
||||
local_repo_url,
|
||||
"v3",
|
||||
None,
|
||||
True,
|
||||
)
|
||||
) == (True, "installed")
|
||||
online_expected = {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"repo_url": "",
|
||||
"package_version": "v3",
|
||||
"release_version": "1.2.3",
|
||||
"force": False,
|
||||
"local_sync": False,
|
||||
"explicit_source": False,
|
||||
}
|
||||
local_expected = {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"repo_url": local_repo_url,
|
||||
"package_version": "v3",
|
||||
"release_version": None,
|
||||
"force": True,
|
||||
"local_sync": True,
|
||||
"explicit_source": True,
|
||||
}
|
||||
assert sync_runner.call_args_list == [
|
||||
call(gateway, **online_expected),
|
||||
call(gateway, **local_expected),
|
||||
]
|
||||
assert async_runner.await_args_list == [
|
||||
call(gateway, **online_expected),
|
||||
call(gateway, **local_expected),
|
||||
]
|
||||
+29
-34
@@ -141,7 +141,6 @@ def _patch_sync_remote_install(helper, monkeypatch, meta: dict,
|
||||
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None)
|
||||
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: calls.append("remove"))
|
||||
monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, ""))
|
||||
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: calls.append("refresh"))
|
||||
|
||||
def fake_release(_pid, _user_repo, _release_tag):
|
||||
calls.append("release")
|
||||
@@ -2080,11 +2079,11 @@ demo = { index = "private" }
|
||||
(True, ""),
|
||||
)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert ["remove", "release", "refresh"] == calls
|
||||
assert ["remove", "release"] == calls
|
||||
|
||||
def test_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch):
|
||||
"""
|
||||
@@ -2104,11 +2103,11 @@ demo = { index = "private" }
|
||||
(True, ""),
|
||||
)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert ["remove", "release", "remove", "filelist", "refresh"] == calls
|
||||
assert ["remove", "release", "remove", "filelist"] == calls
|
||||
|
||||
def test_install_reports_filelist_error_after_release_fallback_fails(self, monkeypatch):
|
||||
"""
|
||||
@@ -2128,7 +2127,7 @@ demo = { index = "private" }
|
||||
(False, "获取文件列表失败"),
|
||||
)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert not success
|
||||
assert "获取文件列表失败" == message
|
||||
@@ -2152,11 +2151,11 @@ demo = { index = "private" }
|
||||
(True, ""),
|
||||
)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert ["remove", "filelist", "refresh"] == calls
|
||||
assert ["remove", "filelist"] == calls
|
||||
|
||||
def test_install_rejects_release_without_version(self, monkeypatch):
|
||||
"""
|
||||
@@ -2175,7 +2174,7 @@ demo = { index = "private" }
|
||||
(True, ""),
|
||||
)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert not success
|
||||
assert f"未在插件清单中找到 {PLUGIN_ID} 的版本号" in message
|
||||
@@ -2199,7 +2198,7 @@ demo = { index = "private" }
|
||||
)
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.0.0"))
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert not success
|
||||
assert "MoviePilot 版本 >=9.0.0" in message
|
||||
@@ -2228,7 +2227,7 @@ demo = { index = "private" }
|
||||
lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}],
|
||||
)
|
||||
|
||||
success, message = helper.install(
|
||||
success, message = helper._PluginHelper__install_package(
|
||||
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.3", force_install=True
|
||||
)
|
||||
|
||||
@@ -2260,7 +2259,7 @@ demo = { index = "private" }
|
||||
lambda *_args: [{"version": "1.2.0", "tag_name": "DemoPlugin_v1.2.0"}],
|
||||
)
|
||||
|
||||
success, message = helper.install(
|
||||
success, message = helper._PluginHelper__install_package(
|
||||
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
|
||||
)
|
||||
|
||||
@@ -2290,7 +2289,7 @@ demo = { index = "private" }
|
||||
lambda *_args: [{"version": "1.2.3", "tag_name": "DemoPlugin_v1.2.3"}],
|
||||
)
|
||||
|
||||
success, message = helper.install(
|
||||
success, message = helper._PluginHelper__install_package(
|
||||
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
|
||||
)
|
||||
|
||||
@@ -2307,7 +2306,7 @@ demo = { index = "private" }
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
success, message = PluginHelper().install("", REPO_URL)
|
||||
success, message = PluginHelper()._PluginHelper__install_package("", REPO_URL)
|
||||
|
||||
assert not success
|
||||
assert "参数错误" == message
|
||||
@@ -2321,7 +2320,7 @@ demo = { index = "private" }
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
success, message = PluginHelper().install(PLUGIN_ID, "not-a-repo-url")
|
||||
success, message = PluginHelper()._PluginHelper__install_package(PLUGIN_ID, "not-a-repo-url")
|
||||
|
||||
assert not success
|
||||
assert "不支持的插件仓库地址格式" == message
|
||||
@@ -2338,7 +2337,7 @@ demo = { index = "private" }
|
||||
helper = PluginHelper()
|
||||
monkeypatch.setattr(helper, "get_plugin_package_version", lambda *_args: None)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL)
|
||||
|
||||
assert not success
|
||||
assert f"{PLUGIN_ID} 没有找到适用于当前版本的插件" == message
|
||||
@@ -2359,10 +2358,9 @@ demo = { index = "private" }
|
||||
monkeypatch.setattr(helper, "_PluginHelper__backup_plugin", lambda _pid: None)
|
||||
monkeypatch.setattr(helper, "_PluginHelper__remove_old_plugin", lambda _pid: None)
|
||||
monkeypatch.setattr(helper, "_PluginHelper__install_dependencies_if_required", lambda _pid: (False, True, ""))
|
||||
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: None)
|
||||
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_sync", lambda *_args: (True, ""))
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, force_install=True)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
@@ -2399,9 +2397,8 @@ demo = { index = "private" }
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("app.adapters.external.market.PLUGIN_DIR", runtime_root)
|
||||
monkeypatch.setattr(helper, "refresh_persistent_plugin_backup", lambda _pid: True)
|
||||
|
||||
success, message = helper.install(
|
||||
success, message = helper._PluginHelper__install_package(
|
||||
PLUGIN_ID,
|
||||
helper.make_local_repo_url(PLUGIN_ID, repo_path, "v2"),
|
||||
force_install=True,
|
||||
@@ -2431,11 +2428,11 @@ demo = { index = "private" }
|
||||
(True, ""),
|
||||
)
|
||||
|
||||
success, message = helper.install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
success, message = helper._PluginHelper__install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert ["remove", "release", "remove", "filelist", "refresh"] == calls
|
||||
assert ["remove", "release", "remove", "filelist"] == calls
|
||||
|
||||
def test_async_install_uses_release_package_when_asset_is_available(self, monkeypatch):
|
||||
"""
|
||||
@@ -2455,13 +2452,12 @@ demo = { index = "private" }
|
||||
)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert calls[:2] == ["remove", "release"]
|
||||
assert calls[2][0] == "to_thread"
|
||||
assert calls == ["remove", "release"]
|
||||
|
||||
def test_async_install_falls_back_to_filelist_when_release_is_missing(self, monkeypatch):
|
||||
"""
|
||||
@@ -2482,13 +2478,12 @@ demo = { index = "private" }
|
||||
)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
)
|
||||
|
||||
assert success
|
||||
assert "" == message
|
||||
assert calls[:4] == ["remove", "release", "remove", "filelist"]
|
||||
assert calls[4][0] == "to_thread"
|
||||
assert calls == ["remove", "release", "remove", "filelist"]
|
||||
|
||||
def test_async_install_old_release_version_uses_release_asset_without_filelist_fallback(self, monkeypatch):
|
||||
"""
|
||||
@@ -2515,7 +2510,7 @@ demo = { index = "private" }
|
||||
monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(
|
||||
helper._PluginHelper__async_install_package(
|
||||
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
|
||||
)
|
||||
)
|
||||
@@ -2547,7 +2542,7 @@ demo = { index = "private" }
|
||||
monkeypatch.setattr(helper, "async_get_plugin_release_versions", fake_releases)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(
|
||||
helper._PluginHelper__async_install_package(
|
||||
PLUGIN_ID, REPO_URL, package_version="v2", release_version="1.2.0", force_install=True
|
||||
)
|
||||
)
|
||||
@@ -2575,7 +2570,7 @@ demo = { index = "private" }
|
||||
)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
)
|
||||
|
||||
assert not success
|
||||
@@ -2608,7 +2603,7 @@ demo = { index = "private" }
|
||||
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
)
|
||||
|
||||
assert success
|
||||
@@ -2641,7 +2636,7 @@ demo = { index = "private" }
|
||||
monkeypatch.setattr(helper, "_PluginHelper__prepare_content_via_filelist_async", fake_filelist)
|
||||
|
||||
success, message = asyncio.run(
|
||||
helper.async_install(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
helper._PluginHelper__async_install_package(PLUGIN_ID, REPO_URL, package_version="v2", force_install=True)
|
||||
)
|
||||
|
||||
assert success
|
||||
@@ -3521,7 +3516,7 @@ demo = { index = "private" }
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
success, message = PluginHelper().install("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins")
|
||||
success, message = PluginHelper()._PluginHelper__install_package("DemoPlugin", "local://OtherPlugin?path=/tmp/plugins")
|
||||
|
||||
assert not success
|
||||
assert "本地插件来源与插件ID不匹配" == message
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""存量插件身份启动迁移的来源和顺序合同测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.identity_migration import PluginIdentityMigrationService
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
LocalCandidateRead,
|
||||
MarketRead,
|
||||
PluginMarketCandidate,
|
||||
)
|
||||
from app.runtime.extensions.plugin.dependency import PluginDependencyInstallResult
|
||||
from app.startup.initializers import plugins as plugins_initializer
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
OFFICIAL_REPO = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
|
||||
THIRD_PARTY_REPO = "https://github.com/example/MoviePilot-Plugins"
|
||||
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
|
||||
|
||||
|
||||
class _Persistence:
|
||||
"""提供可观察 CAS 竞争的内存迁移持久化端口。"""
|
||||
|
||||
def __init__(self, identities: tuple[PluginIdentity, ...] = ()) -> None:
|
||||
self.identities = {
|
||||
identity.normalized_plugin_id: identity for identity in identities
|
||||
}
|
||||
self.fail_create = False
|
||||
self.fail_bind = False
|
||||
|
||||
async def get_identity(self, plugin_id: str) -> PluginIdentity | None:
|
||||
"""按规范物理 ID 返回当前身份。"""
|
||||
return self.identities.get(plugin_id.lower())
|
||||
|
||||
async def migrate_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
"""模拟首次身份 CAS。"""
|
||||
assert expected_revision is None
|
||||
if self.fail_create or identity.normalized_plugin_id in self.identities:
|
||||
raise PluginIdentityConflictError("create conflict")
|
||||
self.identities[identity.normalized_plugin_id] = identity
|
||||
return identity
|
||||
|
||||
async def bind_online_identity(
|
||||
self,
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
"""模拟未绑定身份的 revision CAS。"""
|
||||
current = self.identities.get(identity.normalized_plugin_id)
|
||||
if (
|
||||
self.fail_bind
|
||||
or current is None
|
||||
or current.revision != expected_revision
|
||||
):
|
||||
raise PluginIdentityConflictError("bind conflict")
|
||||
self.identities[identity.normalized_plugin_id] = identity
|
||||
return identity
|
||||
|
||||
|
||||
def _candidate(
|
||||
plugin_id: str,
|
||||
*,
|
||||
source_type: TrustedPluginSourceType,
|
||||
source_key: str,
|
||||
repo_url: str,
|
||||
) -> PluginMarketCandidate:
|
||||
"""构造一个 V3 在线候选。"""
|
||||
return PluginMarketCandidate(
|
||||
plugin_id=plugin_id,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation="v3",
|
||||
plugin_version="1.0.0",
|
||||
)
|
||||
|
||||
|
||||
def _inventory(
|
||||
*candidates: PluginMarketCandidate,
|
||||
failed_market: bool = False,
|
||||
) -> CandidateInventory:
|
||||
"""构造完整或部分失败的市场库存。"""
|
||||
reads = [
|
||||
MarketRead.present(
|
||||
OFFICIAL_REPO,
|
||||
candidates,
|
||||
package_generation="v3",
|
||||
)
|
||||
]
|
||||
expected_markets = [OFFICIAL_REPO]
|
||||
if failed_market:
|
||||
failed_repo = "https://github.com/unavailable/MoviePilot-Plugins"
|
||||
reads.append(
|
||||
MarketRead.failure(
|
||||
failed_repo,
|
||||
"unavailable",
|
||||
package_generation="v3",
|
||||
)
|
||||
)
|
||||
expected_markets.append(failed_repo)
|
||||
return CandidateInventory(
|
||||
market_reads=tuple(reads),
|
||||
expected_markets=tuple(expected_markets),
|
||||
expected_generations=("v3",),
|
||||
local_read=LocalCandidateRead.absent(),
|
||||
)
|
||||
|
||||
|
||||
def _legacy(plugin_id: str = "DemoPlugin") -> PluginIdentity:
|
||||
"""构造尚未绑定在线来源的存量身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
|
||||
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=None,
|
||||
payload_applied_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
persistence: _Persistence,
|
||||
inventory: CandidateInventory,
|
||||
installed: list[str],
|
||||
*,
|
||||
virtual: set[str] | None = None,
|
||||
) -> PluginIdentityMigrationService:
|
||||
"""装配固定库存和安装清单的迁移服务。"""
|
||||
virtual_ids = virtual or set()
|
||||
return PluginIdentityMigrationService(
|
||||
persistence=persistence,
|
||||
inventory=AsyncMock(return_value=inventory),
|
||||
installed_plugins=lambda: installed,
|
||||
is_virtual_instance=lambda plugin_id: plugin_id in virtual_ids,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_binds_official_and_unique_third_party_sources() -> None:
|
||||
"""官方默认和完整库存中的唯一第三方来源都可建立更新绑定。"""
|
||||
persistence = _Persistence()
|
||||
inventory = _inventory(
|
||||
_candidate(
|
||||
"OfficialPlugin",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
source_key=OFFICIAL_SOURCE,
|
||||
repo_url=OFFICIAL_REPO,
|
||||
),
|
||||
_candidate(
|
||||
"ThirdPartyPlugin",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key=THIRD_PARTY_SOURCE,
|
||||
repo_url=THIRD_PARTY_REPO,
|
||||
),
|
||||
)
|
||||
|
||||
result = await _service(
|
||||
persistence,
|
||||
inventory,
|
||||
["OfficialPlugin", "ThirdPartyPlugin", "VirtualPlugin"],
|
||||
virtual={"VirtualPlugin"},
|
||||
).migrate()
|
||||
|
||||
assert result.created == 2
|
||||
assert result.bound == 2
|
||||
assert result.unbound == 0
|
||||
assert result.skipped == 1
|
||||
official = persistence.identities["officialplugin"]
|
||||
third_party = persistence.identities["thirdpartyplugin"]
|
||||
assert official.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
assert official.payload_source_type is PluginPayloadSourceType.UNKNOWN
|
||||
assert third_party.binding_basis is PluginBindingBasis.TOFU
|
||||
assert third_party.payload_source_type is PluginPayloadSourceType.UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failed_market", (False, True))
|
||||
async def test_migration_keeps_ambiguous_or_incomplete_third_party_unbound(
|
||||
failed_market: bool,
|
||||
) -> None:
|
||||
"""多来源或库存读取失败时不得猜测第三方更新来源。"""
|
||||
candidates = (
|
||||
_candidate(
|
||||
"DemoPlugin",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key=THIRD_PARTY_SOURCE,
|
||||
repo_url=THIRD_PARTY_REPO,
|
||||
),
|
||||
)
|
||||
if not failed_market:
|
||||
candidates += (
|
||||
_candidate(
|
||||
"DemoPlugin",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key="github:second/moviepilot-plugins",
|
||||
repo_url="https://github.com/second/MoviePilot-Plugins",
|
||||
),
|
||||
)
|
||||
persistence = _Persistence()
|
||||
|
||||
result = await _service(
|
||||
persistence,
|
||||
_inventory(*candidates, failed_market=failed_market),
|
||||
["DemoPlugin"],
|
||||
).migrate()
|
||||
|
||||
assert result.created == 1
|
||||
assert result.bound == 0
|
||||
assert result.unbound == 1
|
||||
identity = persistence.identities["demoplugin"]
|
||||
assert identity.binding_basis is PluginBindingBasis.LEGACY_UNBOUND
|
||||
assert identity.trusted_source_key is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_later_binds_legacy_identity_without_rewriting_payload() -> None:
|
||||
"""后续市场证据充分时只升级可信来源,不改写未知存量载荷。"""
|
||||
legacy = _legacy("DemoPlugin")
|
||||
persistence = _Persistence((legacy,))
|
||||
inventory = _inventory(
|
||||
_candidate(
|
||||
"demoplugin",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key=THIRD_PARTY_SOURCE,
|
||||
repo_url=THIRD_PARTY_REPO,
|
||||
)
|
||||
)
|
||||
|
||||
result = await _service(
|
||||
persistence,
|
||||
inventory,
|
||||
["demoplugin"],
|
||||
).migrate()
|
||||
|
||||
assert result.bound == 1
|
||||
identity = persistence.identities["demoplugin"]
|
||||
assert identity.plugin_id == "DemoPlugin"
|
||||
assert identity.created_at == legacy.created_at
|
||||
assert identity.revision == 2
|
||||
assert identity.binding_basis is PluginBindingBasis.TOFU
|
||||
assert identity.payload_source_type is PluginPayloadSourceType.UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_accepts_concurrent_create_winner() -> None:
|
||||
"""首次身份 CAS 竞争已有赢家时,迁移跳过而不覆盖最终身份。"""
|
||||
persistence = _Persistence()
|
||||
inventory = _inventory(
|
||||
_candidate(
|
||||
"DemoPlugin",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
source_key=OFFICIAL_SOURCE,
|
||||
repo_url=OFFICIAL_REPO,
|
||||
)
|
||||
)
|
||||
|
||||
async def create_conflict(
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
) -> PluginIdentity:
|
||||
assert expected_revision is None
|
||||
persistence.identities[identity.normalized_plugin_id] = identity
|
||||
raise PluginIdentityConflictError("concurrent create")
|
||||
|
||||
persistence.migrate_identity = create_conflict # type: ignore[method-assign]
|
||||
|
||||
result = await _service(persistence, inventory, ["DemoPlugin"]).migrate()
|
||||
|
||||
assert result.created == 0
|
||||
assert result.skipped == 1
|
||||
assert persistence.identities["demoplugin"].trusted_source_key == OFFICIAL_SOURCE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_accepts_concurrent_bind_winner() -> None:
|
||||
"""存量绑定 CAS 已由其他执行者推进时,迁移保留赢家并幂等结束。"""
|
||||
persistence = _Persistence((_legacy("DemoPlugin"),))
|
||||
inventory = _inventory(
|
||||
_candidate(
|
||||
"DemoPlugin",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key=THIRD_PARTY_SOURCE,
|
||||
repo_url=THIRD_PARTY_REPO,
|
||||
)
|
||||
)
|
||||
|
||||
async def bind_conflict(
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
expected_revision: int,
|
||||
) -> PluginIdentity:
|
||||
assert expected_revision == 1
|
||||
persistence.identities[identity.normalized_plugin_id] = identity
|
||||
raise PluginIdentityConflictError("concurrent bind")
|
||||
|
||||
persistence.bind_online_identity = bind_conflict # type: ignore[method-assign]
|
||||
|
||||
result = await _service(persistence, inventory, ["DemoPlugin"]).migrate()
|
||||
|
||||
assert result.bound == 0
|
||||
assert result.skipped == 1
|
||||
winner = persistence.identities["demoplugin"]
|
||||
assert winner.revision == 2
|
||||
assert winner.trusted_source_key == THIRD_PARTY_SOURCE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_does_not_replace_existing_bound_or_local_identity() -> None:
|
||||
"""重复启动不得覆盖已绑定在线来源或本地开发身份。"""
|
||||
bound = replace(
|
||||
_legacy("BoundPlugin"),
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key=OFFICIAL_SOURCE,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
bound_at=NOW,
|
||||
)
|
||||
local = replace(
|
||||
_legacy("LocalPlugin"),
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="1.0.0-dev",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
persistence = _Persistence((bound, local))
|
||||
inventory = _inventory(
|
||||
_candidate(
|
||||
"BoundPlugin",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
source_key=OFFICIAL_SOURCE,
|
||||
repo_url=OFFICIAL_REPO,
|
||||
),
|
||||
_candidate(
|
||||
"LocalPlugin",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
source_key=OFFICIAL_SOURCE,
|
||||
repo_url=OFFICIAL_REPO,
|
||||
),
|
||||
)
|
||||
|
||||
result = await _service(
|
||||
persistence,
|
||||
inventory,
|
||||
["BoundPlugin", "LocalPlugin", "BOUNDPLUGIN"],
|
||||
).migrate()
|
||||
|
||||
assert result.created == 0
|
||||
assert result.bound == 0
|
||||
assert result.skipped == 3
|
||||
assert persistence.identities["boundplugin"] == bound
|
||||
assert persistence.identities["localplugin"] == local
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collect_online_restore_plugins_requires_trust_and_local_payload() -> None:
|
||||
"""仅在线可信来源仍绑定的本地载荷需要进入启动恢复候选。"""
|
||||
trusted_local = replace(
|
||||
_legacy("TrustedLocal"),
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key=OFFICIAL_SOURCE,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="9.9.10",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "2" * 64,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
local_only = replace(
|
||||
_legacy("LocalOnly"),
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="1.0.0-dev",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "3" * 64,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
online = replace(
|
||||
_legacy("OnlinePayload"),
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key=OFFICIAL_SOURCE,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key=OFFICIAL_SOURCE,
|
||||
declared_version="1.2.0",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "4" * 64,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
persistence = _Persistence((trusted_local, local_only, online))
|
||||
|
||||
result = await plugins_initializer._collect_online_restore_plugins(
|
||||
persistence,
|
||||
["TrustedLocal", "TRUSTEDLOCAL", "LocalOnly", "OnlinePayload", "bad-id"],
|
||||
)
|
||||
|
||||
assert result == {"trustedlocal"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_runs_identity_migration_before_automatic_install(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""启动自动同步必须在存量来源迁移完成后才能读取和替换载荷。"""
|
||||
order: list[str] = []
|
||||
manager = MagicMock()
|
||||
manager.mutation.return_value = nullcontext()
|
||||
|
||||
def sync(_token, *, online_restore_plugins):
|
||||
order.append("sync")
|
||||
assert online_restore_plugins == {"demoplugin"}
|
||||
return []
|
||||
|
||||
manager.sync.side_effect = sync
|
||||
manager.async_install_plugin_missing_dependencies_with_status = AsyncMock(
|
||||
return_value=PluginDependencyInstallResult(missing=[], success=True)
|
||||
)
|
||||
manager.get_plugin_runtime_statuses.return_value = {}
|
||||
manager.classify_plugins.return_value = MagicMock(ready=())
|
||||
manager.running_plugins = {}
|
||||
migration = MagicMock()
|
||||
|
||||
async def migrate() -> None:
|
||||
order.append("migrate")
|
||||
|
||||
migration.migrate = migrate
|
||||
identity = replace(
|
||||
_legacy(),
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key=OFFICIAL_SOURCE,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="9.9.10",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "5" * 64,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
persistence = MagicMock()
|
||||
|
||||
async def get_identity(_plugin_id: str) -> PluginIdentity:
|
||||
order.append("identity")
|
||||
return identity
|
||||
|
||||
persistence.get_identity = get_identity
|
||||
config = MagicMock()
|
||||
config.get.return_value = ["DemoPlugin"]
|
||||
|
||||
async def execute(_loop, task, _name):
|
||||
return task()
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer.global_vars,
|
||||
"CURRENT_EVENT_LOOP",
|
||||
asyncio.get_running_loop(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"configure_plugin_services",
|
||||
lambda: order.append("configure"),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_identity_migration",
|
||||
lambda: migration,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_persistence",
|
||||
lambda: persistence,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is False
|
||||
assert order == ["configure", "migrate", "identity", "sync"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_stops_before_automatic_install_when_identity_migration_fails(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""存量身份无法持久化时,启动同步不得继续读取或替换插件载荷。"""
|
||||
manager = MagicMock()
|
||||
manager.mutation.return_value = nullcontext()
|
||||
migration = MagicMock()
|
||||
migration.migrate = AsyncMock(side_effect=RuntimeError("database unavailable"))
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"configure_plugin_services",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_identity_migration",
|
||||
lambda: migration,
|
||||
)
|
||||
|
||||
assert await plugins_initializer.sync_plugins() is False
|
||||
manager.sync.assert_not_called()
|
||||
@@ -0,0 +1,388 @@
|
||||
"""插件来源身份专用转换命令的 CAS 合同测试。"""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginIdentityConflictError,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore
|
||||
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
|
||||
from app.db.uow import SqlAlchemyUnitOfWork
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
|
||||
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
|
||||
|
||||
|
||||
def _identity(
|
||||
plugin_id: str = "DemoPlugin",
|
||||
*,
|
||||
trusted_source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key: str | None = OFFICIAL_SOURCE,
|
||||
binding_basis: PluginBindingBasis = PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type: PluginPayloadSourceType = PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key: str | None = OFFICIAL_SOURCE,
|
||||
) -> PluginIdentity:
|
||||
"""构造一份带完整在线载荷审计事实的插件身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=trusted_source_type,
|
||||
trusted_source_key=trusted_source_key,
|
||||
binding_basis=binding_basis,
|
||||
payload_source_type=payload_source_type,
|
||||
payload_source_key=payload_source_key,
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW if trusted_source_type is not TrustedPluginSourceType.UNKNOWN else None,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def identity_store(tmp_path):
|
||||
"""创建可验证事务回滚和 revision CAS 的独立 SQLite 身份表。"""
|
||||
engine = sa.create_engine(f"sqlite:///{tmp_path / 'plugin-identity.db'}")
|
||||
PluginIdentityModel.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
try:
|
||||
yield TransactionalPluginIdentityStore(factory)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _third_party_target(identity: PluginIdentity) -> PluginIdentity:
|
||||
"""构造一次明确指向第三方在线仓库的换源目标。"""
|
||||
return replace(
|
||||
identity,
|
||||
trusted_source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
trusted_source_key=THIRD_PARTY_SOURCE,
|
||||
binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE,
|
||||
payload_source_type=PluginPayloadSourceType.THIRD_PARTY,
|
||||
payload_source_key=THIRD_PARTY_SOURCE,
|
||||
declared_version="2.0.0",
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
bound_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
|
||||
def _legacy_identity(plugin_id: str = "DemoPlugin") -> PluginIdentity:
|
||||
"""构造尚未建立可信来源且没有已知载荷的存量身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
|
||||
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=None,
|
||||
payload_applied_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _online_binding_target(
|
||||
identity: PluginIdentity,
|
||||
*,
|
||||
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
|
||||
source_key: str = THIRD_PARTY_SOURCE,
|
||||
updated_at: datetime = NOW + timedelta(seconds=1),
|
||||
) -> PluginIdentity:
|
||||
"""构造用户明确选定在线仓库后的首次绑定目标。"""
|
||||
return replace(
|
||||
identity,
|
||||
trusted_source_type=source_type,
|
||||
trusted_source_key=source_key,
|
||||
binding_basis=PluginBindingBasis.EXPLICIT_INSTALL,
|
||||
payload_source_type=PluginPayloadSourceType(source_type.value),
|
||||
payload_source_key=source_key,
|
||||
declared_version="2.0.0",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "2" * 64,
|
||||
updated_at=updated_at,
|
||||
bound_at=updated_at,
|
||||
payload_applied_at=updated_at,
|
||||
)
|
||||
|
||||
|
||||
def test_change_source_commits_explicit_online_transition(identity_store) -> None:
|
||||
"""显式换源必须保留创建时间并只推进一个 revision。"""
|
||||
original = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
|
||||
changed = identity_store.change_source(
|
||||
_third_party_target(original),
|
||||
expected_revision=original.revision,
|
||||
)
|
||||
|
||||
assert changed.trusted_source_type is TrustedPluginSourceType.THIRD_PARTY
|
||||
assert changed.trusted_source_key == THIRD_PARTY_SOURCE
|
||||
assert changed.payload_source_type is PluginPayloadSourceType.THIRD_PARTY
|
||||
assert changed.payload_source_key == THIRD_PARTY_SOURCE
|
||||
assert changed.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
assert changed.created_at == original.created_at
|
||||
assert changed.revision == original.revision + 1
|
||||
assert identity_store.get(original.plugin_id) == changed
|
||||
|
||||
|
||||
def test_change_source_rejects_revision_competition(identity_store) -> None:
|
||||
"""换源目标使用旧 revision 时不能覆盖已经提交的身份。"""
|
||||
original = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
changed = identity_store.change_source(
|
||||
_third_party_target(original),
|
||||
expected_revision=original.revision,
|
||||
)
|
||||
|
||||
stale_target = replace(
|
||||
_third_party_target(original),
|
||||
trusted_source_key=OFFICIAL_SOURCE,
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
payload_source_key=OFFICIAL_SOURCE,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
updated_at=NOW + timedelta(seconds=2),
|
||||
bound_at=NOW + timedelta(seconds=2),
|
||||
payload_applied_at=NOW + timedelta(seconds=2),
|
||||
)
|
||||
with pytest.raises(PluginIdentityConflictError, match="revision"):
|
||||
identity_store.change_source(stale_target, expected_revision=original.revision)
|
||||
|
||||
assert identity_store.get(original.plugin_id) == changed
|
||||
|
||||
|
||||
def test_change_source_rejects_same_source_and_local_payload(identity_store) -> None:
|
||||
"""换源必须改变实际在线来源,且不能以本地载荷冒充在线换源。"""
|
||||
original = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
|
||||
same_source = replace(
|
||||
original,
|
||||
binding_basis=PluginBindingBasis.EXPLICIT_SOURCE_CHANGE,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
bound_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
)
|
||||
with pytest.raises(PluginIdentityConflictError, match="来源必须变化"):
|
||||
identity_store.change_source(
|
||||
same_source,
|
||||
expected_revision=original.revision,
|
||||
)
|
||||
|
||||
local_payload = replace(
|
||||
_third_party_target(original),
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
payload_source_key=None,
|
||||
)
|
||||
with pytest.raises(PluginIdentityConflictError, match="在线载荷"):
|
||||
identity_store.change_source(
|
||||
local_payload,
|
||||
expected_revision=original.revision,
|
||||
)
|
||||
assert identity_store.get(original.plugin_id) == original
|
||||
|
||||
|
||||
def test_bind_local_commits_only_legacy_unbound_transition(identity_store) -> None:
|
||||
"""本地绑定只能把存量未绑定行转换为本地专属身份。"""
|
||||
legacy = _legacy_identity()
|
||||
original = identity_store.compare_and_set(legacy, expected_revision=None)
|
||||
local = replace(
|
||||
original,
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
changed = identity_store.bind_local(
|
||||
local,
|
||||
expected_revision=original.revision,
|
||||
)
|
||||
|
||||
assert changed.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
assert changed.binding_basis is PluginBindingBasis.LOCAL_ONLY
|
||||
assert changed.payload_source_type is PluginPayloadSourceType.LOCAL
|
||||
assert changed.created_at == original.created_at
|
||||
assert changed.revision == 2
|
||||
assert identity_store.get(original.plugin_id) == changed
|
||||
|
||||
|
||||
def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) -> None:
|
||||
"""显式在线安装可绑定存量未知来源,也可承接先本地开发的插件。"""
|
||||
legacy = identity_store.compare_and_set(
|
||||
_legacy_identity("LegacyPlugin"),
|
||||
expected_revision=None,
|
||||
)
|
||||
legacy_bound = identity_store.bind_online(
|
||||
_online_binding_target(legacy),
|
||||
expected_revision=legacy.revision,
|
||||
)
|
||||
|
||||
local = replace(
|
||||
_legacy_identity("LocalPlugin"),
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
)
|
||||
local = identity_store.compare_and_set(local, expected_revision=None)
|
||||
local_bound = identity_store.bind_online(
|
||||
_online_binding_target(
|
||||
local,
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
source_key=OFFICIAL_SOURCE,
|
||||
updated_at=NOW + timedelta(seconds=2),
|
||||
),
|
||||
expected_revision=local.revision,
|
||||
)
|
||||
|
||||
assert legacy_bound.trusted_source_key == THIRD_PARTY_SOURCE
|
||||
assert legacy_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
|
||||
assert legacy_bound.revision == 2
|
||||
assert local_bound.trusted_source_key == OFFICIAL_SOURCE
|
||||
assert local_bound.payload_source_type is PluginPayloadSourceType.OFFICIAL
|
||||
assert local_bound.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
|
||||
assert local_bound.revision == 2
|
||||
|
||||
|
||||
def test_bind_online_rejects_bound_identity_and_stale_revision(identity_store) -> None:
|
||||
"""首次在线绑定不能覆盖已有可信来源,也不能使用失效 revision。"""
|
||||
bound = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
with pytest.raises(PluginIdentityConflictError, match="未绑定"):
|
||||
identity_store.bind_online(
|
||||
_third_party_target(bound),
|
||||
expected_revision=bound.revision,
|
||||
)
|
||||
|
||||
legacy = identity_store.compare_and_set(
|
||||
_legacy_identity("StalePlugin"),
|
||||
expected_revision=None,
|
||||
)
|
||||
target = _online_binding_target(legacy)
|
||||
identity_store.bind_online(target, expected_revision=legacy.revision)
|
||||
with pytest.raises(PluginIdentityConflictError, match="revision"):
|
||||
identity_store.bind_online(target, expected_revision=legacy.revision)
|
||||
|
||||
|
||||
def test_first_local_install_still_uses_ordinary_create(identity_store) -> None:
|
||||
"""未安装插件的首次本地载荷仍可由普通 create 建立身份。"""
|
||||
local = replace(
|
||||
_identity(),
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
payload_source_key=None,
|
||||
declared_version="2.0.0-dev",
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
bound_at=None,
|
||||
)
|
||||
|
||||
created = identity_store.compare_and_set(local, expected_revision=None)
|
||||
|
||||
assert created.binding_basis is PluginBindingBasis.LOCAL_ONLY
|
||||
assert created.payload_source_type is PluginPayloadSourceType.LOCAL
|
||||
assert created.revision == 1
|
||||
|
||||
|
||||
def test_bind_local_rejects_nonlegacy_state_and_stale_revision(identity_store) -> None:
|
||||
"""本地绑定不能绕过已绑定身份或 revision 条件。"""
|
||||
original = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
local = replace(
|
||||
original,
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
payload_source_key=None,
|
||||
bound_at=None,
|
||||
declared_version="2.0.0-dev",
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
)
|
||||
with pytest.raises(PluginIdentityConflictError, match="legacy_unbound"):
|
||||
identity_store.bind_local(local, expected_revision=original.revision)
|
||||
|
||||
legacy = replace(
|
||||
_identity("LegacyPlugin"),
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
|
||||
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
payload_receipt=None,
|
||||
bound_at=None,
|
||||
payload_applied_at=None,
|
||||
)
|
||||
identity_store.compare_and_set(legacy, expected_revision=None)
|
||||
changed = replace(
|
||||
legacy,
|
||||
binding_basis=PluginBindingBasis.LOCAL_ONLY,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
declared_version="2.0.0-dev",
|
||||
package_generation="v3",
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
updated_at=NOW + timedelta(seconds=1),
|
||||
payload_applied_at=NOW + timedelta(seconds=1),
|
||||
)
|
||||
identity_store.bind_local(changed, expected_revision=1)
|
||||
with pytest.raises(PluginIdentityConflictError, match="revision"):
|
||||
identity_store.bind_local(changed, expected_revision=1)
|
||||
|
||||
|
||||
def test_ordinary_writer_still_rejects_binding_change(identity_store) -> None:
|
||||
"""普通 writer 不能借 CAS 参数伪装成来源绑定转换。"""
|
||||
original = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
with pytest.raises(PluginIdentityConflictError, match="不能改变"):
|
||||
identity_store.compare_and_set(
|
||||
_third_party_target(original),
|
||||
expected_revision=original.revision,
|
||||
)
|
||||
assert identity_store.get(original.plugin_id) == original
|
||||
|
||||
|
||||
def test_transition_rolls_back_when_commit_fails(identity_store, monkeypatch) -> None:
|
||||
"""转换提交失败时必须回滚暂存的身份变化。"""
|
||||
original = identity_store.compare_and_set(_identity(), expected_revision=None)
|
||||
target = _third_party_target(original)
|
||||
|
||||
def fail_commit(_unit_of_work: SqlAlchemyUnitOfWork) -> None:
|
||||
"""模拟数据库提交失败。"""
|
||||
raise RuntimeError("commit failed")
|
||||
|
||||
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
identity_store.change_source(target, expected_revision=original.revision)
|
||||
|
||||
assert identity_store.get(original.plugin_id) == original
|
||||
@@ -0,0 +1,375 @@
|
||||
"""插件来源准入与目标身份规划测试。"""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.admission import (
|
||||
PluginInstallAdmissionRequest,
|
||||
PluginSourceAdmissionError,
|
||||
admit_plugin_install,
|
||||
)
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
MarketRead,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
OFFICIAL = "github:jxxghp/moviepilot-plugins"
|
||||
THIRD_PARTY = "github:example/moviepilot-plugins"
|
||||
|
||||
|
||||
def _online_candidate(
|
||||
*,
|
||||
source_key: str = OFFICIAL,
|
||||
source_type: TrustedPluginSourceType = TrustedPluginSourceType.OFFICIAL,
|
||||
) -> PluginMarketCandidate:
|
||||
"""构造一个可安装在线候选。"""
|
||||
owner_repo = source_key.removeprefix("github:")
|
||||
return PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=f"https://github.com/{owner_repo}",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0",
|
||||
dto={"system_version": ">=3.0.0", "v3": True, "v3t": False},
|
||||
)
|
||||
|
||||
|
||||
def _identity() -> PluginIdentity:
|
||||
"""构造已经绑定官方来源的旧载荷身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key=OFFICIAL,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key=OFFICIAL,
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=3,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _inventory(*candidates, local_candidates=()) -> CandidateInventory:
|
||||
"""构造完整市场库存。"""
|
||||
return CandidateInventory(
|
||||
(MarketRead.present("https://github.com/example/plugins", candidates),),
|
||||
tuple(local_candidates),
|
||||
)
|
||||
|
||||
|
||||
def test_same_source_update_preserves_binding_and_advances_payload() -> None:
|
||||
"""同源更新只推进载荷事实,不改变既有可信绑定依据。"""
|
||||
current = _identity()
|
||||
admission = admit_plugin_install(
|
||||
_inventory(_online_candidate()),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
explicit_source=False,
|
||||
),
|
||||
identity=current,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
applied_at=NOW,
|
||||
)
|
||||
|
||||
assert target.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
assert target.trusted_source_key == OFFICIAL
|
||||
assert target.revision == 4
|
||||
assert target.declared_version == "2.0.0"
|
||||
|
||||
|
||||
def test_first_online_binding_uses_payload_commit_time() -> None:
|
||||
"""首次在线绑定在载荷提交时生效,不能早于身份创建时间。"""
|
||||
applied_at = NOW + timedelta(seconds=1)
|
||||
admission = admit_plugin_install(
|
||||
_inventory(_online_candidate()),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="https://github.com/jxxghp/MoviePilot-Plugins",
|
||||
explicit_source=True,
|
||||
),
|
||||
identity=None,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "6" * 64,
|
||||
applied_at=applied_at,
|
||||
)
|
||||
|
||||
assert target.created_at == applied_at
|
||||
assert target.updated_at == applied_at
|
||||
assert target.bound_at == applied_at
|
||||
assert target.payload_applied_at == applied_at
|
||||
|
||||
|
||||
def test_force_semantics_cannot_authorize_source_change() -> None:
|
||||
"""普通安装即使替换载荷,也不能选择不同于已绑定来源的仓库。"""
|
||||
with pytest.raises(PluginSourceAdmissionError, match="普通安装不能改变"):
|
||||
admit_plugin_install(
|
||||
_inventory(
|
||||
_online_candidate(),
|
||||
_online_candidate(
|
||||
source_key=THIRD_PARTY,
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
),
|
||||
),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="https://github.com/example/moviepilot-plugins",
|
||||
explicit_source=True,
|
||||
),
|
||||
identity=_identity(),
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("revision", [None, 2, 4])
|
||||
def test_source_change_requires_exact_identity_revision(revision: int | None) -> None:
|
||||
"""显式换源必须携带当前身份的精确 revision。"""
|
||||
with pytest.raises(PluginSourceAdmissionError, match="revision"):
|
||||
admit_plugin_install(
|
||||
_inventory(
|
||||
_online_candidate(
|
||||
source_key=THIRD_PARTY,
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
)
|
||||
),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="https://github.com/example/moviepilot-plugins",
|
||||
explicit_source=True,
|
||||
source_change=True,
|
||||
expected_revision=revision,
|
||||
),
|
||||
identity=_identity(),
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
|
||||
def test_source_change_builds_explicit_transition() -> None:
|
||||
"""合法换源把 trusted 与 payload 一起指向明确选择的新仓库。"""
|
||||
candidate = _online_candidate(
|
||||
source_key=THIRD_PARTY,
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
)
|
||||
admission = admit_plugin_install(
|
||||
_inventory(candidate),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url=candidate.repo_url,
|
||||
explicit_source=True,
|
||||
source_change=True,
|
||||
expected_revision=3,
|
||||
),
|
||||
identity=_identity(),
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
applied_at = NOW + timedelta(seconds=1)
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "2" * 64,
|
||||
applied_at=applied_at,
|
||||
)
|
||||
|
||||
assert target.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
assert target.trusted_source_key == THIRD_PARTY
|
||||
assert target.payload_source_key == THIRD_PARTY
|
||||
assert target.bound_at == applied_at
|
||||
|
||||
|
||||
def test_local_payload_preserves_existing_online_trust() -> None:
|
||||
"""本地开发载荷覆盖时保留此前可信在线来源,便于之后同源恢复。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?package_version=v3",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0-dev",
|
||||
dto={"v3": True},
|
||||
)
|
||||
admission = admit_plugin_install(
|
||||
_inventory(local_candidates=(local,)),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url=local.repo_url,
|
||||
explicit_source=True,
|
||||
),
|
||||
identity=_identity(),
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "3" * 64,
|
||||
applied_at=NOW,
|
||||
)
|
||||
|
||||
assert target.trusted_source_key == OFFICIAL
|
||||
assert target.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
assert target.payload_source_type is PluginPayloadSourceType.LOCAL
|
||||
assert target.payload_source_key is None
|
||||
|
||||
|
||||
def test_first_local_payload_creates_local_only_identity() -> None:
|
||||
"""首次本地安装不会伪造在线可信来源。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?package_version=v3",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0-dev",
|
||||
dto={"v3": True},
|
||||
)
|
||||
admission = admit_plugin_install(
|
||||
_inventory(local_candidates=(local,)),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url=local.repo_url,
|
||||
explicit_source=True,
|
||||
),
|
||||
identity=None,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "4" * 64,
|
||||
applied_at=NOW,
|
||||
)
|
||||
|
||||
assert target.binding_basis is PluginBindingBasis.LOCAL_ONLY
|
||||
assert target.trusted_source_type is TrustedPluginSourceType.UNKNOWN
|
||||
|
||||
|
||||
def test_sanitized_local_reference_selects_configured_candidate_without_path() -> None:
|
||||
"""脱敏本地来源标识仍能选择配置内候选,但公共投影不暴露路径。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=(
|
||||
"local://DemoPlugin?path=/private/secret/plugins&version=v3"
|
||||
),
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0-dev",
|
||||
dto={"v3": True, "path": "/private/secret/plugins"},
|
||||
)
|
||||
|
||||
admission = admit_plugin_install(
|
||||
_inventory(local_candidates=(local,)),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="local://DemoPlugin?version=v3",
|
||||
),
|
||||
identity=None,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
assert admission.candidate is local
|
||||
public = admission.candidate.public_dict()
|
||||
assert public == {
|
||||
"plugin_id": "DemoPlugin",
|
||||
"source_type": "local",
|
||||
"package_generation": "v3",
|
||||
"plugin_version": "2.0.0-dev",
|
||||
}
|
||||
assert "/private/secret/plugins" not in str(public)
|
||||
|
||||
|
||||
def test_legacy_identity_can_bind_explicit_online_source() -> None:
|
||||
"""存量未绑定身份可在管理员明确选源后建立在线可信来源。"""
|
||||
legacy = replace(
|
||||
_identity(),
|
||||
trusted_source_type=TrustedPluginSourceType.UNKNOWN,
|
||||
trusted_source_key=None,
|
||||
binding_basis=PluginBindingBasis.LEGACY_UNBOUND,
|
||||
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
supports_v3=None,
|
||||
payload_receipt=None,
|
||||
bound_at=None,
|
||||
payload_applied_at=None,
|
||||
)
|
||||
candidate = _online_candidate(
|
||||
source_key=THIRD_PARTY,
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
)
|
||||
admission = admit_plugin_install(
|
||||
_inventory(candidate),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url=candidate.repo_url,
|
||||
explicit_source=True,
|
||||
),
|
||||
identity=legacy,
|
||||
now=NOW,
|
||||
)
|
||||
|
||||
target = admission.build_identity(
|
||||
payload_receipt="sha256:" + "5" * 64,
|
||||
applied_at=NOW,
|
||||
)
|
||||
|
||||
assert target.binding_basis is PluginBindingBasis.EXPLICIT_INSTALL
|
||||
assert target.trusted_source_key == THIRD_PARTY
|
||||
assert target.revision == 4
|
||||
|
||||
|
||||
def test_source_change_rejects_local_payload_reference() -> None:
|
||||
"""带 revision 的显式换源只能切换在线可信来源。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0-dev",
|
||||
)
|
||||
current = _identity()
|
||||
|
||||
with pytest.raises(
|
||||
PluginSourceAdmissionError,
|
||||
match="显式换源只接受在线插件仓库",
|
||||
):
|
||||
admit_plugin_install(
|
||||
_inventory(local_candidates=(local,)),
|
||||
request=PluginInstallAdmissionRequest(
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_repo_url="local://DemoPlugin?version=v3",
|
||||
explicit_source=True,
|
||||
source_change=True,
|
||||
expected_revision=current.revision,
|
||||
),
|
||||
identity=current,
|
||||
now=NOW,
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
"""统一插件安装 Gateway 测试。"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.gateway import PluginInstallGateway
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
LocalCandidateRead,
|
||||
MarketRead,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
|
||||
|
||||
def _inventory() -> CandidateInventory:
|
||||
"""构造仅含官方候选的完整库存。"""
|
||||
return CandidateInventory((
|
||||
MarketRead.present(
|
||||
REPO_URL,
|
||||
(
|
||||
PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:jxxghp/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
repo_url=REPO_URL,
|
||||
package_generation="v3",
|
||||
plugin_version="1.0.0",
|
||||
dto={"v3": True},
|
||||
),
|
||||
),
|
||||
package_generation="v3",
|
||||
),
|
||||
))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_freezes_admission_before_executing_transaction() -> None:
|
||||
"""Gateway 只把已选中的候选交给事务执行器。"""
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = type(
|
||||
"Result",
|
||||
(),
|
||||
{"success": True, "message": ""},
|
||||
)()
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(return_value=_inventory()),
|
||||
identity=AsyncMock(return_value=None),
|
||||
candidate_compatibility=lambda _candidate: (True, ""),
|
||||
executor=executor,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
result = await gateway.install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
package_version="v3",
|
||||
explicit_source=True,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
admission = executor.execute.await_args.kwargs["admission"]
|
||||
assert admission.candidate.repo_url == REPO_URL
|
||||
assert admission.expected_revision is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_rejects_source_conflict_before_package_execution() -> None:
|
||||
"""来源准入失败时不进入文件和数据库事务。"""
|
||||
other = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:example/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
repo_url="https://github.com/example/moviepilot-plugins",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0",
|
||||
dto={"v3": True},
|
||||
)
|
||||
executor = AsyncMock()
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(
|
||||
return_value=CandidateInventory((
|
||||
MarketRead.present(REPO_URL, (_inventory().online_candidates[0], other)),
|
||||
))
|
||||
),
|
||||
identity=AsyncMock(return_value=None),
|
||||
candidate_compatibility=lambda _candidate: (True, ""),
|
||||
executor=executor,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
result = await gateway.install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=None,
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "source_admission"
|
||||
executor.execute.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_checks_compatibility_on_final_trusted_candidate() -> None:
|
||||
"""跨仓聚合不能替代最终可信候选的系统版本兼容门禁。"""
|
||||
official = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:jxxghp/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
repo_url=REPO_URL,
|
||||
package_generation="v3",
|
||||
plugin_version="1.2.0",
|
||||
dto={"v3": True, "system_version": ">=99"},
|
||||
)
|
||||
competing = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:example/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
repo_url="https://github.com/example/moviepilot-plugins",
|
||||
package_generation="v3",
|
||||
plugin_version="9.9.10",
|
||||
dto={"v3": True},
|
||||
)
|
||||
identity = PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
payload_source_key=None,
|
||||
declared_version="9.9.9",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
payload_receipt="sha256:" + "1" * 64,
|
||||
revision=3,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
compatibility = Mock(return_value=(False, "当前版本不满足插件要求"))
|
||||
executor = AsyncMock()
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(
|
||||
return_value=CandidateInventory((
|
||||
MarketRead.present(REPO_URL, (official,)),
|
||||
MarketRead.present(competing.repo_url, (competing,)),
|
||||
))
|
||||
),
|
||||
identity=AsyncMock(return_value=identity),
|
||||
candidate_compatibility=compatibility,
|
||||
executor=executor,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
result = await gateway.install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=None,
|
||||
package_version="v3",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "source_admission"
|
||||
assert result.message == "当前版本不满足插件要求"
|
||||
compatibility.assert_called_once_with(official)
|
||||
executor.execute.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_source_inspection_preserves_sources_and_hides_local_path() -> None:
|
||||
"""来源查询按在线仓归并版本,本地候选只保留类型与版本。"""
|
||||
official_v3 = _inventory().online_candidates[0]
|
||||
official_v2 = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:jxxghp/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
repo_url=REPO_URL,
|
||||
package_generation="v2",
|
||||
plugin_version="9.0.0",
|
||||
dto={"v2": True},
|
||||
)
|
||||
third_party = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:example/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
repo_url="https://github.com/example/moviepilot-plugins",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0",
|
||||
dto={"v3": True},
|
||||
)
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?path=/private/plugins&version=v3",
|
||||
package_generation="v3",
|
||||
plugin_version="3.0.0-dev",
|
||||
dto={"path": "/private/plugins", "v3": True},
|
||||
)
|
||||
inventory = CandidateInventory(
|
||||
(
|
||||
MarketRead.present(REPO_URL, (official_v3,), package_generation="v3"),
|
||||
MarketRead.present(REPO_URL, (official_v2,), package_generation="v2"),
|
||||
MarketRead.present(
|
||||
third_party.repo_url,
|
||||
(third_party,),
|
||||
package_generation="v3",
|
||||
),
|
||||
),
|
||||
(local,),
|
||||
local_read=LocalCandidateRead.present((local,)),
|
||||
)
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(return_value=inventory),
|
||||
identity=AsyncMock(return_value=None),
|
||||
candidate_compatibility=lambda _candidate: (True, ""),
|
||||
executor=AsyncMock(),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
inspection = await gateway.inspect_source(plugin_id="DemoPlugin")
|
||||
|
||||
assert [candidate.source_key for candidate in inspection.online_candidates] == [
|
||||
"github:jxxghp/moviepilot-plugins",
|
||||
"github:example/moviepilot-plugins",
|
||||
]
|
||||
assert inspection.online_candidates[0].package_generation == "v3"
|
||||
assert inspection.local_candidate is local
|
||||
assert "/private/plugins" not in str(inspection.local_candidate.public_dict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_forwards_explicit_source_change_revision() -> None:
|
||||
"""显式换源的目标来源和 revision 必须冻结到事务准入结果。"""
|
||||
current = PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="1.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=4,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
candidate = PluginMarketCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
source_key="github:example/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
repo_url="https://github.com/example/moviepilot-plugins",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0",
|
||||
dto={"v3": True},
|
||||
)
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = type(
|
||||
"Result",
|
||||
(),
|
||||
{"success": True, "message": ""},
|
||||
)()
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(
|
||||
return_value=CandidateInventory((MarketRead.present(REPO_URL, (candidate,)),))
|
||||
),
|
||||
identity=AsyncMock(return_value=current),
|
||||
candidate_compatibility=lambda _candidate: (True, ""),
|
||||
executor=executor,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
result = await gateway.install(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url=candidate.repo_url,
|
||||
explicit_source=True,
|
||||
source_change=True,
|
||||
expected_revision=4,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
admission = executor.execute.await_args.kwargs["admission"]
|
||||
assert admission.identity_before == current
|
||||
assert admission.expected_revision == 4
|
||||
assert admission.binding_basis is PluginBindingBasis.EXPLICIT_SOURCE_CHANGE
|
||||
assert admission.trusted_source_key == candidate.source_key
|
||||
@@ -0,0 +1,728 @@
|
||||
"""插件安装事务记录、SQLite CAS 和 membership 测试。"""
|
||||
|
||||
import copy
|
||||
import importlib
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
try:
|
||||
import psycopg2 as postgres_driver
|
||||
from psycopg2 import sql
|
||||
|
||||
POSTGRESQL_DIALECT = "postgresql+psycopg2"
|
||||
except ModuleNotFoundError:
|
||||
import psycopg as postgres_driver
|
||||
from psycopg import sql
|
||||
|
||||
POSTGRESQL_DIALECT = "postgresql+psycopg"
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationConflictError,
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
PluginInstallationRecordError,
|
||||
)
|
||||
from app.db.adapters.plugininstallation import TransactionalPluginInstallationStore
|
||||
from app.db.models.pluginidentity import PluginIdentity as PluginIdentityModel
|
||||
from app.db.models.plugininstallation import PluginInstallation
|
||||
from app.db.models.systemconfig import SystemConfig
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _identity(
|
||||
*,
|
||||
plugin_id: str = "DemoPlugin",
|
||||
revision: int = 1,
|
||||
version: str = "1.0.0",
|
||||
) -> PluginIdentity:
|
||||
"""构造一份满足来源身份合同的测试身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id=plugin_id,
|
||||
normalized_plugin_id=plugin_id.lower(),
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version=version,
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=False,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=revision,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _record(**overrides) -> PluginInstallationRecord:
|
||||
"""构造可跨进程恢复的安装事务记录。"""
|
||||
values = {
|
||||
"transaction_id": "txn-demo-1",
|
||||
"plugin_id": "DemoPlugin",
|
||||
"phase": PluginInstallationPhase.PREPARED,
|
||||
"membership_before": True,
|
||||
"membership_target": None,
|
||||
"identity_before_revision": 1,
|
||||
"identity_target_revision": None,
|
||||
"package_existed": True,
|
||||
"persistent_backup_existed": True,
|
||||
"created_at": NOW,
|
||||
"updated_at": NOW,
|
||||
}
|
||||
values.update(overrides)
|
||||
return PluginInstallationRecord(**values)
|
||||
|
||||
|
||||
def test_record_keeps_plugin_level_recovery_contract() -> None:
|
||||
"""事务只记录目标插件 membership、CAS revision 和备份存在性。"""
|
||||
record = _record(
|
||||
phase="committed",
|
||||
membership_target=True,
|
||||
identity_target_revision=2,
|
||||
)
|
||||
|
||||
assert record.phase is PluginInstallationPhase.COMMITTED
|
||||
assert record.membership_before is True
|
||||
assert record.membership_target is True
|
||||
assert record.identity_before_revision == 1
|
||||
assert record.identity_target_revision == 2
|
||||
assert record.package_existed is True
|
||||
assert record.persistent_backup_existed is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"transaction_id": "bad id"},
|
||||
{"plugin_id": " DemoPlugin"},
|
||||
{"membership_before": 1},
|
||||
{"membership_target": 1},
|
||||
{"identity_before_revision": 0},
|
||||
{"identity_target_revision": True},
|
||||
{"package_existed": 1},
|
||||
{"created_at": NOW.replace(tzinfo=None)},
|
||||
{"updated_at": NOW.replace(year=2025)},
|
||||
{"phase": "committed"},
|
||||
],
|
||||
)
|
||||
def test_record_rejects_invalid_recovery_invariants(overrides: dict) -> None:
|
||||
"""事务记录必须拒绝不能用于 CAS 或补偿恢复的状态。"""
|
||||
with pytest.raises(PluginInstallationRecordError):
|
||||
_record(**overrides)
|
||||
|
||||
|
||||
def test_committed_record_requires_target_membership() -> None:
|
||||
"""COMMITTED 不能指向尚未登记的业务目标。"""
|
||||
with pytest.raises(PluginInstallationRecordError):
|
||||
_record(phase=PluginInstallationPhase.COMMITTED)
|
||||
|
||||
|
||||
def test_record_schema_version_is_explicit() -> None:
|
||||
"""恢复读取必须拒绝未知 schema version。"""
|
||||
with pytest.raises(PluginInstallationRecordError):
|
||||
_record(schema_version=2)
|
||||
|
||||
|
||||
def test_record_is_immutable() -> None:
|
||||
"""事务记录提交后不能被调用方原地修改。"""
|
||||
record = _record()
|
||||
with pytest.raises(AttributeError):
|
||||
record.membership_before = False # type: ignore[misc]
|
||||
|
||||
assert replace(record, membership_before=False).membership_before is False
|
||||
|
||||
|
||||
class _AtomicSystemConfig:
|
||||
"""用测试 Session 模拟 SystemConfigOper 的配置锁和原子提交。"""
|
||||
|
||||
def __init__(self, factory) -> None:
|
||||
self._factory = factory
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def update_atomically(self, key, mutation):
|
||||
"""在测试数据库事务中锁定配置并执行关联写入。"""
|
||||
with self._lock:
|
||||
session = self._factory()
|
||||
try:
|
||||
with session.begin():
|
||||
config = session.execute(
|
||||
sa.select(SystemConfig)
|
||||
.where(SystemConfig.key == key)
|
||||
.with_for_update()
|
||||
).scalar_one_or_none()
|
||||
current = copy.deepcopy(config.value if config else None)
|
||||
result, value = mutation(session, current)
|
||||
if config is None:
|
||||
session.add(SystemConfig(key=key, value=copy.deepcopy(value)))
|
||||
else:
|
||||
config.value = copy.deepcopy(value)
|
||||
session.flush()
|
||||
return result
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def installation_store(tmp_path):
|
||||
"""创建带配置、身份和事务表的隔离 SQLite Store。"""
|
||||
engine = sa.create_engine(
|
||||
f"sqlite:///{tmp_path / 'plugin-installation.db'}",
|
||||
connect_args={"check_same_thread": False, "timeout": 5},
|
||||
)
|
||||
for model in (SystemConfig, PluginIdentityModel, PluginInstallation):
|
||||
model.__table__.create(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
system_config = _AtomicSystemConfig(factory)
|
||||
try:
|
||||
yield engine, factory, TransactionalPluginInstallationStore(
|
||||
factory,
|
||||
system_config.update_atomically,
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _store_record(
|
||||
*,
|
||||
transaction_id: str,
|
||||
plugin_id: str = "DemoPlugin",
|
||||
membership_before: bool = False,
|
||||
identity_before_revision: int | None = None,
|
||||
) -> PluginInstallationRecord:
|
||||
"""构造 Store 测试用的 PREPARED 记录。"""
|
||||
return PluginInstallationRecord(
|
||||
transaction_id=transaction_id,
|
||||
plugin_id=plugin_id,
|
||||
phase=PluginInstallationPhase.PREPARED,
|
||||
membership_before=membership_before,
|
||||
membership_target=None,
|
||||
identity_before_revision=identity_before_revision,
|
||||
identity_target_revision=None,
|
||||
package_existed=membership_before,
|
||||
persistent_backup_existed=False,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _identity_model(identity: PluginIdentity) -> PluginIdentityModel:
|
||||
"""把应用身份转换为测试数据库模型。"""
|
||||
return PluginIdentityModel(
|
||||
plugin_id=identity.plugin_id,
|
||||
normalized_plugin_id=identity.normalized_plugin_id,
|
||||
trusted_source_type=identity.trusted_source_type.value,
|
||||
trusted_source_key=identity.trusted_source_key,
|
||||
binding_basis=identity.binding_basis.value,
|
||||
payload_source_type=identity.payload_source_type.value,
|
||||
payload_source_key=identity.payload_source_key,
|
||||
declared_version=identity.declared_version,
|
||||
package_generation=identity.package_generation,
|
||||
supports_v3=identity.supports_v3,
|
||||
supports_v3t=identity.supports_v3t,
|
||||
payload_receipt=identity.payload_receipt,
|
||||
revision=identity.revision,
|
||||
created_at=identity.created_at.isoformat(),
|
||||
updated_at=identity.updated_at.isoformat(),
|
||||
bound_at=identity.bound_at.isoformat() if identity.bound_at else None,
|
||||
payload_applied_at=(
|
||||
identity.payload_applied_at.isoformat()
|
||||
if identity.payload_applied_at
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _set_config(factory, value: list[str]) -> None:
|
||||
"""直接准备测试用的安装清单。"""
|
||||
with factory() as session:
|
||||
config = session.execute(
|
||||
sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins")
|
||||
).scalar_one_or_none()
|
||||
if config is None:
|
||||
session.add(SystemConfig(key="UserInstalledPlugins", value=value))
|
||||
else:
|
||||
config.value = value
|
||||
session.commit()
|
||||
|
||||
|
||||
def _get_config(factory) -> list[str] | None:
|
||||
"""读取测试用的安装清单。"""
|
||||
with factory() as session:
|
||||
config = session.execute(
|
||||
sa.select(SystemConfig).where(SystemConfig.key == "UserInstalledPlugins")
|
||||
).scalar_one_or_none()
|
||||
return copy.deepcopy(config.value) if config else None
|
||||
|
||||
|
||||
def _upgrade_migration(connection, module_name: str) -> None:
|
||||
"""在当前隔离 schema 中按生产 Alembic 路径执行迁移。"""
|
||||
migration = importlib.import_module(module_name)
|
||||
original_op = migration.op
|
||||
try:
|
||||
migration.op = Operations(MigrationContext.configure(connection))
|
||||
migration.upgrade()
|
||||
finally:
|
||||
migration.op = original_op
|
||||
|
||||
|
||||
def _set_identity_revision(
|
||||
factory,
|
||||
revision: int,
|
||||
plugin_id: str = "DemoPlugin",
|
||||
) -> None:
|
||||
"""模拟事务外的身份 revision 更新。"""
|
||||
with factory() as session:
|
||||
identity = session.execute(
|
||||
sa.select(PluginIdentityModel).where(
|
||||
PluginIdentityModel.normalized_plugin_id == plugin_id.lower()
|
||||
)
|
||||
).scalar_one()
|
||||
identity.revision = revision
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_store_round_trips_plugin_level_journal(installation_store) -> None:
|
||||
"""SQLite 往返只保留插件级 membership、revision 和备份标记。"""
|
||||
_, _, store = installation_store
|
||||
record = _store_record(transaction_id="install-roundtrip")
|
||||
|
||||
store.create(record)
|
||||
|
||||
restored = store.get(record.transaction_id)
|
||||
assert restored == record
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"phase",
|
||||
[PluginInstallationPhase.PREPARED, PluginInstallationPhase.COMMITTED],
|
||||
)
|
||||
def test_store_blocks_new_journal_until_previous_phase_is_closed(
|
||||
installation_store,
|
||||
phase: PluginInstallationPhase,
|
||||
) -> None:
|
||||
"""同一物理插件的未收尾 journal 不得被后续事务覆盖。"""
|
||||
_, _, store = installation_store
|
||||
existing = _store_record(transaction_id=f"install-{phase.value}")
|
||||
if phase is PluginInstallationPhase.COMMITTED:
|
||||
existing = replace(existing, phase=phase, membership_target=True)
|
||||
store.create(existing)
|
||||
|
||||
with pytest.raises(PluginInstallationConflictError, match="未收尾安装事务"):
|
||||
store.create(
|
||||
_store_record(
|
||||
transaction_id="install-follow-up",
|
||||
plugin_id="demoplugin",
|
||||
)
|
||||
)
|
||||
|
||||
assert store.get(existing.transaction_id).phase is phase
|
||||
assert store.delete(
|
||||
existing.transaction_id,
|
||||
expected_phase=phase,
|
||||
) is True
|
||||
assert store.create(
|
||||
_store_record(
|
||||
transaction_id="install-follow-up",
|
||||
plugin_id="demoplugin",
|
||||
)
|
||||
).transaction_id == "install-follow-up"
|
||||
|
||||
|
||||
def test_store_commits_membership_identity_and_phase_atomically(installation_store) -> None:
|
||||
"""membership、身份和 journal phase 必须在一个配置原子事务中提交。"""
|
||||
_, factory, store = installation_store
|
||||
before = _identity()
|
||||
target = replace(
|
||||
before,
|
||||
declared_version="2.0.0",
|
||||
revision=2,
|
||||
updated_at=NOW.replace(second=1),
|
||||
payload_applied_at=NOW.replace(second=1),
|
||||
)
|
||||
_set_config(factory, ["OtherPlugin"])
|
||||
with factory() as session:
|
||||
session.add(_identity_model(before))
|
||||
session.commit()
|
||||
|
||||
store.create(
|
||||
_store_record(
|
||||
transaction_id="install-atomic",
|
||||
identity_before_revision=before.revision,
|
||||
)
|
||||
)
|
||||
staged = store.set_target(
|
||||
"install-atomic",
|
||||
membership_target=True,
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
assert staged.identity_target_revision == target.revision
|
||||
|
||||
committed = store.commit_target(
|
||||
"install-atomic",
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
assert committed.phase is PluginInstallationPhase.COMMITTED
|
||||
assert _get_config(factory) == ["OtherPlugin", "DemoPlugin"]
|
||||
with factory() as session:
|
||||
identity = session.execute(
|
||||
sa.select(PluginIdentityModel).where(
|
||||
PluginIdentityModel.normalized_plugin_id == "demoplugin"
|
||||
)
|
||||
).scalar_one()
|
||||
assert identity.revision == 2
|
||||
|
||||
|
||||
def test_store_preserves_other_plugin_membership(installation_store) -> None:
|
||||
"""目标插件提交不能用旧完整清单覆盖其他插件。"""
|
||||
_, factory, store = installation_store
|
||||
_set_config(factory, ["OtherPlugin"])
|
||||
store.create(_store_record(transaction_id="install-narrow"))
|
||||
store.set_target(
|
||||
"install-narrow",
|
||||
membership_target=True,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
_set_config(factory, ["OtherPlugin", "AnotherPlugin"])
|
||||
committed = store.commit_target(
|
||||
"install-narrow",
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
assert committed.phase is PluginInstallationPhase.COMMITTED
|
||||
assert _get_config(factory) == ["OtherPlugin", "AnotherPlugin", "DemoPlugin"]
|
||||
|
||||
|
||||
def test_store_rejects_target_identity_revision_jump(installation_store) -> None:
|
||||
"""最终写者必须拒绝跳号 revision,避免绕过后续来源 CAS。"""
|
||||
_, factory, store = installation_store
|
||||
before = _identity()
|
||||
with factory() as session:
|
||||
session.add(_identity_model(before))
|
||||
session.commit()
|
||||
store.create(
|
||||
_store_record(
|
||||
transaction_id="install-revision-jump",
|
||||
identity_before_revision=before.revision,
|
||||
)
|
||||
)
|
||||
jumped = replace(
|
||||
before,
|
||||
revision=before.revision + 2,
|
||||
updated_at=NOW.replace(second=1),
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationConflictError, match="必须为 2"):
|
||||
store.set_target(
|
||||
"install-revision-jump",
|
||||
membership_target=True,
|
||||
identity_target=jumped,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
assert store.get("install-revision-jump").identity_target_revision is None
|
||||
|
||||
|
||||
def test_store_rejects_membership_and_identity_cas_drift(installation_store) -> None:
|
||||
"""同一插件 membership 或 identity revision 漂移时拒绝覆盖。"""
|
||||
_, factory, store = installation_store
|
||||
before = _identity()
|
||||
with factory() as session:
|
||||
session.add(_identity_model(before))
|
||||
session.commit()
|
||||
store.create(
|
||||
_store_record(
|
||||
transaction_id="install-drift",
|
||||
identity_before_revision=before.revision,
|
||||
)
|
||||
)
|
||||
target = replace(before, revision=2, updated_at=NOW.replace(second=1))
|
||||
store.set_target(
|
||||
"install-drift",
|
||||
membership_target=True,
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
_set_config(factory, ["DemoPlugin"])
|
||||
with pytest.raises(PluginInstallationConflictError, match="membership"):
|
||||
store.commit_target(
|
||||
"install-drift",
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
assert store.get("install-drift").phase is PluginInstallationPhase.PREPARED
|
||||
|
||||
_set_config(factory, [])
|
||||
_set_identity_revision(factory, 3)
|
||||
with pytest.raises(PluginInstallationConflictError, match="revision"):
|
||||
store.commit_target(
|
||||
"install-drift",
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
|
||||
def _commit_or_conflict(store, transaction_id: str) -> str:
|
||||
"""把 phase CAS 竞争转换为可断言的测试结果。"""
|
||||
try:
|
||||
store.commit_target(
|
||||
transaction_id,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
except PluginInstallationConflictError:
|
||||
return "conflict"
|
||||
return "committed"
|
||||
|
||||
|
||||
def test_store_serializes_membership_commits_and_phase_cas(installation_store) -> None:
|
||||
"""SQLite 下不同插件并发提交应合并,重复提交同一事务只能失败。"""
|
||||
_, factory, store = installation_store
|
||||
first = _store_record(transaction_id="install-first")
|
||||
second = _store_record(transaction_id="install-second", plugin_id="OtherPlugin")
|
||||
store.create(first)
|
||||
store.create(second)
|
||||
store.set_target(
|
||||
first.transaction_id,
|
||||
membership_target=True,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
store.set_target(
|
||||
second.transaction_id,
|
||||
membership_target=True,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
def commit(record_id: str):
|
||||
return store.commit_target(
|
||||
record_id,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(commit, [first.transaction_id, second.transaction_id])
|
||||
)
|
||||
assert {result.phase for result in results} == {
|
||||
PluginInstallationPhase.COMMITTED,
|
||||
}
|
||||
assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"}
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(
|
||||
executor.map(
|
||||
lambda _: _commit_or_conflict(store, first.transaction_id),
|
||||
range(2),
|
||||
)
|
||||
)
|
||||
assert outcomes == ["conflict", "conflict"]
|
||||
|
||||
|
||||
def test_store_delete_is_idempotent_after_recovery(installation_store) -> None:
|
||||
"""恢复处理重复清理同一 journal 时不产生第二次副作用。"""
|
||||
_, _, store = installation_store
|
||||
store.create(_store_record(transaction_id="install-delete"))
|
||||
|
||||
assert store.delete(
|
||||
"install-delete",
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
) is True
|
||||
assert store.delete(
|
||||
"install-delete",
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
) is False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def postgresql_installation_stores():
|
||||
"""创建两个不共享进程锁的 PostgreSQL Store,验证数据库并发合同。"""
|
||||
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
|
||||
host = os.getenv(f"{prefix}HOST")
|
||||
database = os.getenv(f"{prefix}DATABASE")
|
||||
username = os.getenv(f"{prefix}USERNAME")
|
||||
if not host or not database or not username:
|
||||
pytest.skip("未配置隔离 PostgreSQL transaction 测试库")
|
||||
|
||||
port = os.getenv(f"{prefix}PORT", "5432")
|
||||
password = os.getenv(f"{prefix}PASSWORD", "")
|
||||
schema = f"plugin_transaction_{uuid.uuid4().hex}"
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
user=username,
|
||||
password=password,
|
||||
) as connection:
|
||||
connection.autocommit = True
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
|
||||
)
|
||||
|
||||
engine = sa.create_engine(
|
||||
sa.URL.create(
|
||||
POSTGRESQL_DIALECT,
|
||||
username=username,
|
||||
password=password,
|
||||
host=host,
|
||||
port=int(port),
|
||||
database=database,
|
||||
),
|
||||
connect_args={"options": f"-csearch_path={schema}"},
|
||||
)
|
||||
SystemConfig.__table__.create(engine)
|
||||
with engine.begin() as connection:
|
||||
_upgrade_migration(
|
||||
connection,
|
||||
"database.versions.d2e4f6a8b0c1_3_0_9",
|
||||
)
|
||||
_upgrade_migration(
|
||||
connection,
|
||||
"database.versions.e4f7a1b2c3d5_3_0_10",
|
||||
)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
_set_config(factory, [])
|
||||
first = TransactionalPluginInstallationStore(
|
||||
factory,
|
||||
_AtomicSystemConfig(factory).update_atomically,
|
||||
)
|
||||
second = TransactionalPluginInstallationStore(
|
||||
factory,
|
||||
_AtomicSystemConfig(factory).update_atomically,
|
||||
)
|
||||
try:
|
||||
yield factory, first, second
|
||||
finally:
|
||||
engine.dispose()
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
user=username,
|
||||
password=password,
|
||||
) as connection:
|
||||
connection.autocommit = True
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
|
||||
sql.Identifier(schema)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_postgresql_store_serializes_membership_phase_and_revision_cas(
|
||||
postgresql_installation_stores,
|
||||
) -> None:
|
||||
"""PostgreSQL 行锁必须合并不同插件写入并拒绝 phase/revision 竞争。"""
|
||||
factory, first_store, second_store = postgresql_installation_stores
|
||||
first = _store_record(transaction_id="postgres-first")
|
||||
second = _store_record(
|
||||
transaction_id="postgres-second",
|
||||
plugin_id="OtherPlugin",
|
||||
)
|
||||
for store, record in ((first_store, first), (second_store, second)):
|
||||
store.create(record)
|
||||
store.set_target(
|
||||
record.transaction_id,
|
||||
membership_target=True,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
results = list(
|
||||
executor.map(
|
||||
lambda item: item[0].commit_target(
|
||||
item[1].transaction_id,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
),
|
||||
((first_store, first), (second_store, second)),
|
||||
)
|
||||
)
|
||||
|
||||
assert {result.phase for result in results} == {
|
||||
PluginInstallationPhase.COMMITTED,
|
||||
}
|
||||
assert set(_get_config(factory) or []) == {"DemoPlugin", "OtherPlugin"}
|
||||
|
||||
race = _store_record(
|
||||
transaction_id="postgres-phase-race",
|
||||
plugin_id="RacePlugin",
|
||||
)
|
||||
first_store.create(race)
|
||||
first_store.set_target(
|
||||
race.transaction_id,
|
||||
membership_target=True,
|
||||
identity_target=None,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
barrier = threading.Barrier(2)
|
||||
|
||||
def commit_race(store) -> str:
|
||||
barrier.wait()
|
||||
return _commit_or_conflict(store, race.transaction_id)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
outcomes = list(executor.map(commit_race, (first_store, second_store)))
|
||||
assert sorted(outcomes) == ["committed", "conflict"]
|
||||
|
||||
before = _identity(plugin_id="RevisionPlugin")
|
||||
with factory() as session:
|
||||
session.add(_identity_model(before))
|
||||
session.commit()
|
||||
revision = _store_record(
|
||||
transaction_id="postgres-revision",
|
||||
plugin_id=before.plugin_id,
|
||||
identity_before_revision=before.revision,
|
||||
)
|
||||
first_store.create(revision)
|
||||
target = replace(
|
||||
before,
|
||||
revision=2,
|
||||
updated_at=NOW.replace(second=1),
|
||||
)
|
||||
first_store.set_target(
|
||||
revision.transaction_id,
|
||||
membership_target=True,
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
_set_identity_revision(factory, 3, plugin_id=before.plugin_id)
|
||||
|
||||
with pytest.raises(PluginInstallationConflictError, match="revision"):
|
||||
second_store.commit_target(
|
||||
revision.transaction_id,
|
||||
identity_target=target,
|
||||
expected_phase=PluginInstallationPhase.PREPARED,
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""插件安装事务表 Alembic 迁移测试。"""
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
|
||||
try:
|
||||
import psycopg2 as postgres_driver
|
||||
from psycopg2 import sql
|
||||
|
||||
POSTGRESQL_DIALECT = "postgresql+psycopg2"
|
||||
except ModuleNotFoundError:
|
||||
import psycopg as postgres_driver
|
||||
from psycopg import sql
|
||||
|
||||
POSTGRESQL_DIALECT = "postgresql+psycopg"
|
||||
|
||||
from app.db.models.plugininstallation import PluginInstallation
|
||||
|
||||
MIGRATION = "database.versions.e4f7a1b2c3d5_3_0_10"
|
||||
|
||||
|
||||
def _bind_migration(monkeypatch, connection):
|
||||
"""把迁移绑定到隔离数据库连接。"""
|
||||
migration = importlib.import_module(MIGRATION)
|
||||
monkeypatch.setattr(
|
||||
migration,
|
||||
"op",
|
||||
Operations(MigrationContext.configure(connection)),
|
||||
)
|
||||
return migration
|
||||
|
||||
|
||||
def test_plugin_installation_migration_upgrade_downgrade_reupgrade(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""SQLite 应支持重复升级、回滚和再次升级,字段与 ORM 保持一致。"""
|
||||
engine = sa.create_engine("sqlite://")
|
||||
with engine.begin() as connection:
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
inspector = sa.inspect(connection)
|
||||
assert "plugininstallation" in inspector.get_table_names()
|
||||
assert {
|
||||
column["name"] for column in inspector.get_columns("plugininstallation")
|
||||
} == {column.name for column in PluginInstallation.__table__.columns}
|
||||
assert {
|
||||
index["name"] for index in inspector.get_indexes("plugininstallation")
|
||||
} == {
|
||||
"ix_plugininstallation_plugin_id",
|
||||
"ix_plugininstallation_phase",
|
||||
}
|
||||
|
||||
migration.downgrade()
|
||||
assert "plugininstallation" not in sa.inspect(connection).get_table_names()
|
||||
|
||||
migration.upgrade()
|
||||
assert "plugininstallation" in sa.inspect(connection).get_table_names()
|
||||
|
||||
|
||||
def test_plugin_installation_migration_runs_on_postgresql(monkeypatch) -> None:
|
||||
"""隔离 PostgreSQL 应真实执行安装事务表的升级、约束和回滚。"""
|
||||
prefix = "MOVIEPILOT_TEST_POSTGRESQL_"
|
||||
host = os.getenv(f"{prefix}HOST")
|
||||
database = os.getenv(f"{prefix}DATABASE")
|
||||
username = os.getenv(f"{prefix}USERNAME")
|
||||
if not host or not database or not username:
|
||||
pytest.skip("未配置隔离 PostgreSQL migration 测试库")
|
||||
|
||||
port = os.getenv(f"{prefix}PORT", "5432")
|
||||
password = os.getenv(f"{prefix}PASSWORD", "")
|
||||
schema = f"plugin_installation_{uuid.uuid4().hex}"
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
user=username,
|
||||
password=password,
|
||||
) as connection:
|
||||
connection.autocommit = True
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))
|
||||
)
|
||||
|
||||
engine = None
|
||||
try:
|
||||
engine = sa.create_engine(
|
||||
sa.URL.create(
|
||||
POSTGRESQL_DIALECT,
|
||||
username=username,
|
||||
password=password,
|
||||
host=host,
|
||||
port=int(port),
|
||||
database=database,
|
||||
),
|
||||
connect_args={"options": f"-csearch_path={schema}"},
|
||||
)
|
||||
with engine.begin() as connection:
|
||||
migration = _bind_migration(monkeypatch, connection)
|
||||
migration.upgrade()
|
||||
migration.upgrade()
|
||||
|
||||
inspector = sa.inspect(connection)
|
||||
assert "plugininstallation" in inspector.get_table_names()
|
||||
constraints = {
|
||||
constraint["name"]
|
||||
for constraint in inspector.get_unique_constraints(
|
||||
"plugininstallation"
|
||||
)
|
||||
}
|
||||
assert "uq_plugininstallation_transaction_id" in constraints
|
||||
|
||||
migration.downgrade()
|
||||
assert "plugininstallation" not in sa.inspect(connection).get_table_names()
|
||||
finally:
|
||||
if engine is not None:
|
||||
engine.dispose()
|
||||
with postgres_driver.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
dbname=database,
|
||||
user=username,
|
||||
password=password,
|
||||
) as connection:
|
||||
connection.autocommit = True
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(
|
||||
sql.Identifier(schema)
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,289 @@
|
||||
"""插件安装 journal 启动重放与阻断边界测试。"""
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.recovery import (
|
||||
PluginInstallationRecoveryError,
|
||||
PluginInstallationRecoveryService,
|
||||
)
|
||||
from app.application.plugin.transaction import (
|
||||
PluginInstallationPhase,
|
||||
PluginInstallationRecord,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc)
|
||||
RECEIPT = "sha256:" + "1" * 64
|
||||
|
||||
|
||||
def _identity(*, revision: int = 2, receipt: str = RECEIPT) -> PluginIdentity:
|
||||
"""构造一份已提交载荷身份。"""
|
||||
return PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.OFFICIAL,
|
||||
payload_source_key="github:jxxghp/moviepilot-plugins",
|
||||
declared_version="2.0.0",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=True,
|
||||
payload_receipt=receipt,
|
||||
revision=revision,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
bound_at=NOW,
|
||||
payload_applied_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
*,
|
||||
phase: PluginInstallationPhase,
|
||||
transaction_id: str = "txn-demo",
|
||||
) -> PluginInstallationRecord:
|
||||
"""构造 PREPARED 或 COMMITTED 恢复记录。"""
|
||||
committed = phase is PluginInstallationPhase.COMMITTED
|
||||
return PluginInstallationRecord(
|
||||
transaction_id=transaction_id,
|
||||
plugin_id="DemoPlugin",
|
||||
phase=phase,
|
||||
membership_before=True,
|
||||
membership_target=True if committed else None,
|
||||
identity_before_revision=1,
|
||||
identity_target_revision=2 if committed else None,
|
||||
package_existed=True,
|
||||
persistent_backup_existed=True,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
class _Persistence:
|
||||
"""保存恢复测试所需 journal、身份和删除故障。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
records: list[PluginInstallationRecord],
|
||||
*,
|
||||
identity: PluginIdentity | None = None,
|
||||
delete_errors: list[Exception | None] | None = None,
|
||||
) -> None:
|
||||
self.records = {record.transaction_id: record for record in records}
|
||||
self.identity = identity
|
||||
self.delete_errors = list(delete_errors or [])
|
||||
self.delete_calls: list[tuple[str, PluginInstallationPhase]] = []
|
||||
|
||||
async def list_installations(self) -> list[PluginInstallationRecord]:
|
||||
"""按创建顺序返回当前 journal。"""
|
||||
return list(self.records.values())
|
||||
|
||||
async def get_identity(self, _plugin_id: str) -> PluginIdentity | None:
|
||||
"""返回已提交身份。"""
|
||||
return self.identity
|
||||
|
||||
async def delete_installation(
|
||||
self,
|
||||
transaction_id: str,
|
||||
*,
|
||||
expected_phase: PluginInstallationPhase,
|
||||
) -> bool:
|
||||
"""按 phase 删除 journal,并可注入一次性错误。"""
|
||||
self.delete_calls.append((transaction_id, expected_phase))
|
||||
if self.delete_errors:
|
||||
error = self.delete_errors.pop(0)
|
||||
if error is not None:
|
||||
raise error
|
||||
record = self.records.get(transaction_id)
|
||||
if record is None:
|
||||
return False
|
||||
assert record.phase is expected_phase
|
||||
del self.records[transaction_id]
|
||||
return True
|
||||
|
||||
|
||||
def _packages(**overrides):
|
||||
"""构造恢复服务消费的单一包事务端口。"""
|
||||
checkpoint = SimpleNamespace(
|
||||
plugin_existed=True,
|
||||
persistent_backup_existed=True,
|
||||
)
|
||||
values = {
|
||||
"restore_checkpoint": Mock(return_value=checkpoint),
|
||||
"async_restore": AsyncMock(),
|
||||
"async_cleanup": AsyncMock(),
|
||||
"async_committed_payload_receipt": AsyncMock(return_value=RECEIPT),
|
||||
"async_finalize_persistent_backup": AsyncMock(),
|
||||
"async_commit": AsyncMock(),
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepared_replay_restores_before_releasing_journal() -> None:
|
||||
"""PREPARED 必须先恢复旧载荷,再删除 journal 和恢复材料。"""
|
||||
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
|
||||
packages = _packages()
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
result = await service.replay()
|
||||
|
||||
assert result.restored == 1
|
||||
assert persistence.records == {}
|
||||
packages.async_restore.assert_awaited_once()
|
||||
packages.async_cleanup.assert_awaited_once()
|
||||
assert persistence.delete_calls == [
|
||||
("txn-demo", PluginInstallationPhase.PREPARED)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepared_delete_failure_keeps_replayable_journal() -> None:
|
||||
"""恢复完成但 journal 删除失败时,下次启动仍可幂等重放。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.PREPARED)],
|
||||
delete_errors=[RuntimeError("database unavailable"), None],
|
||||
)
|
||||
packages = _packages()
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationRecoveryError, match="未提交安装恢复失败"):
|
||||
await service.replay()
|
||||
assert "txn-demo" in persistence.records
|
||||
packages.async_cleanup.assert_not_awaited()
|
||||
|
||||
result = await service.replay()
|
||||
|
||||
assert result.restored == 1
|
||||
assert persistence.records == {}
|
||||
assert packages.async_restore.await_count == 2
|
||||
packages.async_cleanup.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepared_restore_failure_blocks_plugin_import() -> None:
|
||||
"""旧载荷无法恢复时必须保留 journal,并让启动阶段失败。"""
|
||||
persistence = _Persistence([_record(phase=PluginInstallationPhase.PREPARED)])
|
||||
packages = _packages(
|
||||
async_restore=AsyncMock(side_effect=RuntimeError("snapshot missing"))
|
||||
)
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationRecoveryError, match="snapshot missing"):
|
||||
await service.replay()
|
||||
|
||||
assert "txn-demo" in persistence.records
|
||||
assert persistence.delete_calls == []
|
||||
packages.async_cleanup.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_committed_replay_verifies_identity_and_receipt_before_cleanup() -> None:
|
||||
"""COMMITTED 只在身份 revision 和载荷收据一致时完成幂等收尾。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.COMMITTED)],
|
||||
identity=_identity(),
|
||||
)
|
||||
packages = _packages()
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
result = await service.replay()
|
||||
|
||||
assert result.finalized == 1
|
||||
assert persistence.records == {}
|
||||
packages.async_committed_payload_receipt.assert_awaited_once()
|
||||
packages.async_finalize_persistent_backup.assert_awaited_once()
|
||||
packages.async_commit.assert_awaited_once()
|
||||
assert persistence.delete_calls == [
|
||||
("txn-demo", PluginInstallationPhase.COMMITTED)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("identity", "receipt", "message"),
|
||||
[
|
||||
(replace(_identity(), revision=3), RECEIPT, "身份与安装 journal 不一致"),
|
||||
(_identity(), "sha256:" + "2" * 64, "载荷收据不一致"),
|
||||
],
|
||||
)
|
||||
async def test_committed_fact_mismatch_blocks_plugin_import(
|
||||
identity: PluginIdentity,
|
||||
receipt: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""已提交数据库事实与可恢复载荷不一致时不得继续加载插件。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.COMMITTED)],
|
||||
identity=identity,
|
||||
)
|
||||
packages = _packages(
|
||||
async_committed_payload_receipt=AsyncMock(return_value=receipt)
|
||||
)
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
with pytest.raises(PluginInstallationRecoveryError, match=message):
|
||||
await service.replay()
|
||||
|
||||
assert "txn-demo" in persistence.records
|
||||
packages.async_finalize_persistent_backup.assert_not_awaited()
|
||||
packages.async_commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_committed_cleanup_failure_is_retried_without_rollback() -> None:
|
||||
"""COMMITTED 收尾失败只保留 journal,下一次启动继续清理。"""
|
||||
persistence = _Persistence(
|
||||
[_record(phase=PluginInstallationPhase.COMMITTED)],
|
||||
identity=_identity(),
|
||||
)
|
||||
package_commit = AsyncMock(
|
||||
side_effect=[RuntimeError("snapshot busy"), None]
|
||||
)
|
||||
packages = _packages(async_commit=package_commit)
|
||||
service = PluginInstallationRecoveryService(
|
||||
persistence=persistence,
|
||||
packages=packages,
|
||||
)
|
||||
|
||||
first = await service.replay()
|
||||
|
||||
assert first.cleanup_pending == 1
|
||||
assert "txn-demo" in persistence.records
|
||||
assert persistence.delete_calls == []
|
||||
|
||||
second = await service.replay()
|
||||
|
||||
assert second.finalized == 1
|
||||
assert persistence.records == {}
|
||||
assert packages.async_finalize_persistent_backup.await_count == 2
|
||||
assert package_commit.await_count == 2
|
||||
@@ -0,0 +1,168 @@
|
||||
"""插件生命周期协调器的启动 owner/token 契约测试。"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.lifecycle import PluginLifecycleCoordinator
|
||||
|
||||
|
||||
async def _assert_event_waits(event: asyncio.Event) -> None:
|
||||
"""确认事件在短预算内仍未发生,避免测试依赖固定 sleep 时序。"""
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(asyncio.shield(event.wait()), timeout=0.03)
|
||||
|
||||
|
||||
async def _cancel_task(task: asyncio.Task) -> None:
|
||||
"""取消仍在等待生命周期资格的任务并消费其终态。"""
|
||||
task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_scope_yields_opaque_token_for_matching_plugin_hold() -> None:
|
||||
"""启动 owner 取得的 token 可让内部取得逐插件资格。"""
|
||||
coordinator = PluginLifecycleCoordinator()
|
||||
|
||||
async with coordinator.hold_startup() as startup_token:
|
||||
assert startup_token is not None
|
||||
assert not isinstance(startup_token, (str, bytes, int, bool))
|
||||
|
||||
entered = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def hold_plugin() -> None:
|
||||
async with coordinator.hold("DemoPlugin", startup_token):
|
||||
entered.set()
|
||||
await release.wait()
|
||||
|
||||
task = asyncio.create_task(hold_plugin())
|
||||
await entered.wait()
|
||||
assert coordinator._active_plugins == {"demoplugin"}
|
||||
release.set()
|
||||
await task
|
||||
|
||||
assert coordinator._active_plugins == set()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_external_and_duplicate_plugin_holds_remain_blocked() -> None:
|
||||
"""启动内部的逐插件资格不向外部调用放行,且同插件仍保持互斥。"""
|
||||
coordinator = PluginLifecycleCoordinator()
|
||||
internal_release = asyncio.Event()
|
||||
duplicate_release = asyncio.Event()
|
||||
internal_entered = asyncio.Event()
|
||||
duplicate_entered = asyncio.Event()
|
||||
external_entered = asyncio.Event()
|
||||
|
||||
async with coordinator.hold_startup() as startup_token:
|
||||
|
||||
async def internal_hold() -> None:
|
||||
async with coordinator.hold("DemoPlugin", startup_token):
|
||||
internal_entered.set()
|
||||
await internal_release.wait()
|
||||
|
||||
async def duplicate_hold() -> None:
|
||||
async with coordinator.hold("demoplugin", startup_token):
|
||||
duplicate_entered.set()
|
||||
await duplicate_release.wait()
|
||||
|
||||
async def external_hold() -> None:
|
||||
async with coordinator.hold("DemoPlugin"):
|
||||
external_entered.set()
|
||||
|
||||
internal_task = asyncio.create_task(internal_hold())
|
||||
await internal_entered.wait()
|
||||
duplicate_task = asyncio.create_task(duplicate_hold())
|
||||
external_task = asyncio.create_task(external_hold())
|
||||
|
||||
await _assert_event_waits(duplicate_entered)
|
||||
await _assert_event_waits(external_entered)
|
||||
|
||||
internal_release.set()
|
||||
await internal_task
|
||||
await duplicate_entered.wait()
|
||||
await _assert_event_waits(external_entered)
|
||||
duplicate_release.set()
|
||||
await duplicate_task
|
||||
|
||||
await external_entered.wait()
|
||||
await external_task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_foreign_and_expired_tokens_cannot_bypass_current_startup_lease() -> None:
|
||||
"""其他 coordinator 或旧 lease 的 token 不得绕过当前启动 owner。"""
|
||||
first = PluginLifecycleCoordinator()
|
||||
second = PluginLifecycleCoordinator()
|
||||
|
||||
async with first.hold_startup() as foreign_token:
|
||||
async with second.hold_startup() as current_token:
|
||||
assert foreign_token is not current_token
|
||||
foreign_entered = asyncio.Event()
|
||||
|
||||
async def foreign_hold() -> None:
|
||||
async with second.hold("DemoPlugin", foreign_token):
|
||||
foreign_entered.set()
|
||||
|
||||
foreign_task = asyncio.create_task(foreign_hold())
|
||||
await _assert_event_waits(foreign_entered)
|
||||
await _cancel_task(foreign_task)
|
||||
|
||||
async with second.hold_startup() as expired_token:
|
||||
pass
|
||||
|
||||
async with second.hold_startup() as current_token:
|
||||
assert expired_token is not current_token
|
||||
expired_entered = asyncio.Event()
|
||||
|
||||
async def expired_hold() -> None:
|
||||
async with second.hold("DemoPlugin", expired_token):
|
||||
expired_entered.set()
|
||||
|
||||
expired_task = asyncio.create_task(expired_hold())
|
||||
await _assert_event_waits(expired_entered)
|
||||
await _cancel_task(expired_task)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_token_can_cross_threads_without_contextvar() -> None:
|
||||
"""显式 token 可跨线程传递,资格判断不依赖隐式 ContextVar。"""
|
||||
coordinator = PluginLifecycleCoordinator()
|
||||
main_thread = threading.current_thread().name
|
||||
|
||||
def run_in_thread(startup_token: object) -> tuple[str, set[str]]:
|
||||
async def hold_plugin() -> tuple[str, set[str]]:
|
||||
async with coordinator.hold("DemoPlugin", startup_token):
|
||||
return threading.current_thread().name, set(coordinator._active_plugins)
|
||||
|
||||
return asyncio.run(hold_plugin())
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="plugin-startup") as executor:
|
||||
async with coordinator.hold_startup() as startup_token:
|
||||
result = await asyncio.wrap_future(
|
||||
executor.submit(run_in_thread, startup_token)
|
||||
)
|
||||
|
||||
assert result[0] != main_thread
|
||||
assert result[1] == {"demoplugin"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hold_without_token_retains_startup_waiting_compatibility() -> None:
|
||||
"""无参数调用继续遵守启动全局资格的等待语义。"""
|
||||
coordinator = PluginLifecycleCoordinator()
|
||||
async with coordinator.hold_startup():
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def external_hold() -> None:
|
||||
async with coordinator.hold("DemoPlugin"):
|
||||
entered.set()
|
||||
|
||||
task = asyncio.create_task(external_hold())
|
||||
await _assert_event_waits(entered)
|
||||
await _cancel_task(task)
|
||||
@@ -8,17 +8,40 @@ import pytest
|
||||
from packaging.version import Version
|
||||
from watchfiles import Change
|
||||
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.events import Event, eventmanager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.runtime.extensions.plugin.system import get_plugin_system
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import EventType, SystemConfigKey
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_manager() -> Iterator[PluginManager]:
|
||||
def plugin_manager(monkeypatch) -> Iterator[PluginManager]:
|
||||
"""构造隔离的插件管理器实例,避免单例状态污染其它用例。"""
|
||||
system = get_plugin_system()
|
||||
|
||||
def install_local(**kwargs) -> tuple[bool, str]:
|
||||
"""用测试包适配器模拟已通过来源准入的本地 Gateway。"""
|
||||
repo_url = kwargs["repo_url"]
|
||||
candidate = system.local_candidate(
|
||||
kwargs["plugin_id"],
|
||||
package_version=kwargs.get("package_version"),
|
||||
repo_path=PluginHelper.parse_local_repo_path(repo_url),
|
||||
strict_system_version=False,
|
||||
)
|
||||
if not candidate:
|
||||
return False, "本地候选不存在"
|
||||
return (
|
||||
system.package.sync_local(
|
||||
kwargs["plugin_id"],
|
||||
Path(candidate["path"]),
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(system, "install", install_local)
|
||||
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||
manager = PluginManager()
|
||||
yield manager
|
||||
@@ -65,6 +88,7 @@ def _configure_local_watcher(
|
||||
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
|
||||
ROOT_PATH=tmp_path,
|
||||
TEMP_PATH=tmp_path / "temp",
|
||||
CONFIG_PATH=tmp_path / "config",
|
||||
VERSION_FLAG="v2",
|
||||
)
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
|
||||
@@ -143,6 +167,7 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l
|
||||
DEV=True,
|
||||
ROOT_PATH=tmp_path,
|
||||
TEMP_PATH=tmp_path / "temp",
|
||||
CONFIG_PATH=tmp_path / "config",
|
||||
)
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
|
||||
monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub)
|
||||
|
||||
@@ -5,6 +5,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from app.adapters.external.market import PluginHelper
|
||||
from app.adapters.external.plugin.client import PluginMarketClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -78,3 +79,113 @@ def test_plugin_index_response_preserves_status_contract(
|
||||
result = PluginHelper._resolve_plugin_index_response(status_code, content)
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "content", "expected"),
|
||||
[
|
||||
(200, '{"DemoPlugin": {"version": "1.2.3"}}', {"DemoPlugin": {"version": "1.2.3"}}),
|
||||
(404, "404: Not Found", None),
|
||||
],
|
||||
)
|
||||
def test_plugin_index_result_preserves_read_state(
|
||||
monkeypatch,
|
||||
status_code: int,
|
||||
content: str,
|
||||
expected: dict | None,
|
||||
) -> None:
|
||||
"""只读入口以值和 None 区分真实索引与确定不存在。"""
|
||||
helper = PluginHelper()
|
||||
repo_url = f"https://github.com/policy-owner/policy-repository-{status_code}"
|
||||
|
||||
def request(_url: str, *, headers: dict):
|
||||
return SimpleNamespace(status_code=status_code, text=content)
|
||||
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request)
|
||||
helper.get_plugin_index_result.cache_clear()
|
||||
|
||||
result = helper.get_plugin_index_result(repo_url, "v3")
|
||||
|
||||
assert result == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "content", "message"),
|
||||
[
|
||||
(500, "upstream failed", "插件索引请求失败:HTTP 500"),
|
||||
(200, "not-json", "插件索引响应格式无效"),
|
||||
],
|
||||
)
|
||||
def test_plugin_index_result_raises_for_unusable_reads(
|
||||
monkeypatch,
|
||||
status_code: int,
|
||||
content: str,
|
||||
message: str,
|
||||
) -> None:
|
||||
"""不可判定读取必须抛错,由应用库存统一记录失败事实。"""
|
||||
helper = PluginHelper()
|
||||
|
||||
def request(_url: str, *, headers: dict):
|
||||
return SimpleNamespace(status_code=status_code, text=content)
|
||||
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request)
|
||||
helper.get_plugin_index_result.cache_clear()
|
||||
|
||||
with pytest.raises(RuntimeError, match=message):
|
||||
helper.get_plugin_index_result(
|
||||
f"https://github.com/policy-owner/policy-failed-{status_code}",
|
||||
"v3",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_plugin_index_result_preserves_absent_state(monkeypatch) -> None:
|
||||
"""异步只读入口也必须保留 404 不存在事实。"""
|
||||
helper = PluginHelper()
|
||||
|
||||
async def request(_url: str, *, headers: dict):
|
||||
return SimpleNamespace(status_code=404, text="404: Not Found")
|
||||
|
||||
monkeypatch.setattr(
|
||||
helper,
|
||||
"_PluginHelper__async_request_with_fallback",
|
||||
request,
|
||||
)
|
||||
await helper.async_get_plugin_index_result.cache_clear()
|
||||
|
||||
result = await helper.async_get_plugin_index_result(
|
||||
"https://github.com/policy-owner/policy-repository-async",
|
||||
"v3",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_plugin_index_result_propagates_adapter_exception(monkeypatch) -> None:
|
||||
"""请求异常必须传播给应用库存统一转换为失败事实。"""
|
||||
helper = PluginHelper()
|
||||
|
||||
def request(_url: str, *, headers: dict):
|
||||
raise OSError("socket closed")
|
||||
|
||||
monkeypatch.setattr(helper, "_PluginHelper__request_with_fallback", request)
|
||||
helper.get_plugin_index_result.cache_clear()
|
||||
|
||||
with pytest.raises(OSError, match="socket closed"):
|
||||
helper.get_plugin_index_result(
|
||||
"https://github.com/policy-owner/policy-repository-exception",
|
||||
"v3",
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_market_client_exposes_index_result_port() -> None:
|
||||
"""市场客户端应原样转发索引读取结果并保留只读边界。"""
|
||||
expected = {"DemoPlugin": {"version": "1.2.3"}}
|
||||
|
||||
class FakeHelper:
|
||||
def get_plugin_index_result(self, repo_url: str, package_version: str | None):
|
||||
return expected
|
||||
|
||||
client = PluginMarketClient(FakeHelper())
|
||||
|
||||
assert client.get_plugin_index_result("https://github.com/example/repo", "v3") is expected
|
||||
|
||||
@@ -154,6 +154,30 @@ def _patch_sync_plugins(monkeypatch, manager: MagicMock) -> MagicMock:
|
||||
asyncio.get_running_loop(),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
|
||||
migration = MagicMock()
|
||||
migration.migrate = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_identity_migration",
|
||||
lambda: migration,
|
||||
)
|
||||
config = MagicMock()
|
||||
config.get.return_value = []
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_persistence",
|
||||
MagicMock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"_collect_online_restore_plugins",
|
||||
AsyncMock(return_value=set()),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "execute_task", execute)
|
||||
monkeypatch.setattr(plugins_initializer, "register_plugin_api", register)
|
||||
@@ -347,6 +371,30 @@ async def test_sync_plugins_keeps_event_loop_responsive_during_activation(
|
||||
return_value=PluginDependencyInstallResult(missing=[], success=True),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "configure_plugin_services", lambda: None)
|
||||
migration = MagicMock()
|
||||
migration.migrate = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_identity_migration",
|
||||
lambda: migration,
|
||||
)
|
||||
config = MagicMock()
|
||||
config.get.return_value = []
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_configured_system_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"get_plugin_persistence",
|
||||
MagicMock,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins_initializer,
|
||||
"_collect_online_restore_plugins",
|
||||
AsyncMock(return_value=set()),
|
||||
)
|
||||
monkeypatch.setattr(plugins_initializer, "PluginManager", lambda: manager)
|
||||
monkeypatch.setattr(plugins_initializer, "register_plugin_api", MagicMock())
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -12,7 +12,11 @@ def _manager(monkeypatch, tmp_path: Path) -> PluginPackageManager:
|
||||
"""构造使用隔离运行目录和事务目录的插件包管理器。"""
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.settings",
|
||||
SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"),
|
||||
SimpleNamespace(
|
||||
ROOT_PATH=tmp_path,
|
||||
TEMP_PATH=tmp_path / "temp",
|
||||
CONFIG_PATH=tmp_path / "config",
|
||||
),
|
||||
)
|
||||
return PluginPackageManager(helper=Mock())
|
||||
|
||||
@@ -66,6 +70,145 @@ def test_rollback_does_not_delete_package_when_snapshot_is_missing(monkeypatch,
|
||||
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "new"
|
||||
|
||||
|
||||
def test_durable_checkpoint_stages_backup_without_overwriting_current_backup(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""数据库提交前只准备新备份,现有容器恢复材料保持可用。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
||||
lambda: True,
|
||||
)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
backup_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
|
||||
(backup_dir / "__init__.py").write_text("old", encoding="utf-8")
|
||||
|
||||
checkpoint = manager.checkpoint("DemoPlugin", "txn-1")
|
||||
manager.stage_persistent_backup(checkpoint)
|
||||
|
||||
assert checkpoint.transaction_dir.parent == tmp_path / "config" / "plugin_transactions"
|
||||
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old"
|
||||
assert checkpoint.backup_staging_dir is not None
|
||||
assert (checkpoint.backup_staging_dir / "__init__.py").read_text(
|
||||
encoding="utf-8"
|
||||
) == "new"
|
||||
|
||||
|
||||
def test_activate_and_finalize_persistent_backup_are_retryable(monkeypatch, tmp_path):
|
||||
"""备份激活保留旧载荷,数据库提交后的清理可以重复执行。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
||||
lambda: True,
|
||||
)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
backup_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("new", encoding="utf-8")
|
||||
(backup_dir / "__init__.py").write_text("old", encoding="utf-8")
|
||||
checkpoint = manager.checkpoint("DemoPlugin", "txn-2")
|
||||
manager.stage_persistent_backup(checkpoint)
|
||||
|
||||
manager.activate_persistent_backup(checkpoint)
|
||||
manager.activate_persistent_backup(checkpoint)
|
||||
|
||||
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "new"
|
||||
assert checkpoint.backup_staging_dir is not None
|
||||
assert not checkpoint.backup_staging_dir.exists()
|
||||
assert checkpoint.backup_previous_dir is not None
|
||||
assert (checkpoint.backup_previous_dir / "__init__.py").read_text(
|
||||
encoding="utf-8"
|
||||
) == "old"
|
||||
|
||||
manager.finalize_persistent_backup(checkpoint)
|
||||
manager.finalize_persistent_backup(checkpoint)
|
||||
|
||||
assert not checkpoint.backup_previous_dir.exists()
|
||||
|
||||
|
||||
def test_rollback_removes_staging_but_preserves_current_backup(monkeypatch, tmp_path):
|
||||
"""提交前失败只恢复运行目录,不修改上一份容器恢复备份。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
||||
lambda: True,
|
||||
)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
backup_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8")
|
||||
(backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8")
|
||||
checkpoint = manager.checkpoint("DemoPlugin", "txn-3")
|
||||
(plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8")
|
||||
manager.stage_persistent_backup(checkpoint)
|
||||
|
||||
manager.rollback(checkpoint)
|
||||
|
||||
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime"
|
||||
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup"
|
||||
assert checkpoint.backup_staging_dir is not None
|
||||
assert not checkpoint.backup_staging_dir.exists()
|
||||
|
||||
|
||||
def test_rollback_after_backup_activation_restores_previous_backup(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
"""数据库提交前失败时,已激活的新备份必须回退到上一份载荷。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
||||
lambda: True,
|
||||
)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
backup_dir = tmp_path / "config" / "plugins_backup" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
backup_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("old-runtime", encoding="utf-8")
|
||||
(backup_dir / "__init__.py").write_text("old-backup", encoding="utf-8")
|
||||
checkpoint = manager.checkpoint("DemoPlugin", "txn-4")
|
||||
(plugin_dir / "__init__.py").write_text("new-runtime", encoding="utf-8")
|
||||
manager.stage_persistent_backup(checkpoint)
|
||||
manager.activate_persistent_backup(checkpoint)
|
||||
|
||||
manager.rollback(checkpoint)
|
||||
|
||||
assert (plugin_dir / "__init__.py").read_text(encoding="utf-8") == "old-runtime"
|
||||
assert (backup_dir / "__init__.py").read_text(encoding="utf-8") == "old-backup"
|
||||
|
||||
|
||||
def test_restore_checkpoint_derives_only_controlled_paths(monkeypatch, tmp_path):
|
||||
"""崩溃回放只按事务 ID 在受控根目录内重建文件引用。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.package.SystemUtils.is_docker",
|
||||
lambda: True,
|
||||
)
|
||||
|
||||
checkpoint = manager.restore_checkpoint(
|
||||
plugin_id="DemoPlugin",
|
||||
transaction_id="txn-5",
|
||||
plugin_existed=True,
|
||||
persistent_backup_existed=False,
|
||||
)
|
||||
|
||||
assert checkpoint.transaction_dir == (
|
||||
tmp_path / "config" / "plugin_transactions" / "txn-5"
|
||||
)
|
||||
assert checkpoint.backup_staging_dir == (
|
||||
tmp_path / "config" / "plugins_backup" / ".demoplugin.staging-txn-5"
|
||||
)
|
||||
assert checkpoint.backup_previous_dir == (
|
||||
tmp_path / "config" / "plugins_backup" / ".demoplugin.previous-txn-5"
|
||||
)
|
||||
|
||||
|
||||
def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path):
|
||||
"""本地来源不可复制时不得丢失已经运行的插件副本。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.lifecycle import PluginStartupLease
|
||||
from app.runtime.config import global_vars
|
||||
from app.startup import lifecycle
|
||||
|
||||
@@ -12,10 +13,12 @@ from app.startup import lifecycle
|
||||
async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch) -> None:
|
||||
"""插件 ready 只在调度任务和命令注册完成后对外可见。"""
|
||||
order: list[str] = []
|
||||
startup_tokens: list[PluginStartupLease] = []
|
||||
manager = MagicMock()
|
||||
command_future = Future()
|
||||
|
||||
async def sync_plugins() -> bool:
|
||||
async def sync_plugins(startup_token: PluginStartupLease) -> bool:
|
||||
startup_tokens.append(startup_token)
|
||||
order.append("plugins")
|
||||
return True
|
||||
|
||||
@@ -70,3 +73,5 @@ async def test_runtime_ready_waits_for_scheduler_and_command_refresh(monkeypatch
|
||||
"settling:False",
|
||||
"monitor",
|
||||
]
|
||||
assert len(startup_tokens) == 1
|
||||
assert isinstance(startup_tokens[0], PluginStartupLease)
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""插件候选事实与来源选择策略测试。"""
|
||||
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
LocalCandidateRead,
|
||||
MarketRead,
|
||||
PluginLocalCandidate,
|
||||
PluginMarketCandidate,
|
||||
PluginSelectionStatus,
|
||||
select_plugin_candidate,
|
||||
)
|
||||
|
||||
OFFICIAL_SOURCE = "github:jxxghp/moviepilot-plugins"
|
||||
THIRD_PARTY_SOURCE = "github:example/moviepilot-plugins"
|
||||
OTHER_SOURCE = "github:other/moviepilot-plugins"
|
||||
|
||||
|
||||
def _online(
|
||||
source_key: str,
|
||||
*,
|
||||
source_type: TrustedPluginSourceType = TrustedPluginSourceType.THIRD_PARTY,
|
||||
version: str = "1.0.0",
|
||||
generation: str = "v3",
|
||||
plugin_id: str = "DemoPlugin",
|
||||
repo_url: str = "https://github.com/example/moviepilot-plugins",
|
||||
) -> PluginMarketCandidate:
|
||||
"""构造测试用在线候选。"""
|
||||
return PluginMarketCandidate(
|
||||
plugin_id=plugin_id,
|
||||
source_key=source_key,
|
||||
source_type=source_type,
|
||||
repo_url=repo_url,
|
||||
package_generation=generation,
|
||||
plugin_version=version,
|
||||
dto={"id": plugin_id, "version": version},
|
||||
)
|
||||
|
||||
|
||||
def _inventory(*reads: MarketRead, local=()) -> CandidateInventory:
|
||||
"""构造测试用候选快照。"""
|
||||
return CandidateInventory(tuple(reads), tuple(local))
|
||||
|
||||
|
||||
def _identity(source_type: TrustedPluginSourceType, source_key: str) -> PluginIdentity:
|
||||
"""构造已绑定在线来源身份。"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime(2026, 8, 25, tzinfo=timezone.utc)
|
||||
return PluginIdentity(
|
||||
plugin_id="DemoPlugin",
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=source_type,
|
||||
trusted_source_key=source_key,
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT
|
||||
if source_type is TrustedPluginSourceType.OFFICIAL
|
||||
else PluginBindingBasis.TOFU,
|
||||
payload_source_type=PluginPayloadSourceType.UNKNOWN,
|
||||
payload_source_key=None,
|
||||
declared_version=None,
|
||||
package_generation=None,
|
||||
system_version=None,
|
||||
supports_v3=None,
|
||||
supports_v3t=None,
|
||||
payload_receipt=None,
|
||||
revision=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
bound_at=now,
|
||||
payload_applied_at=None,
|
||||
)
|
||||
|
||||
|
||||
def test_cross_source_high_version_does_not_win() -> None:
|
||||
"""已绑定来源过滤必须先于版本比较,跨源高版本不能覆盖允许来源。"""
|
||||
inventory = _inventory(
|
||||
MarketRead.present(
|
||||
"market-a",
|
||||
(
|
||||
_online(THIRD_PARTY_SOURCE, version="1.0.0"),
|
||||
_online(OTHER_SOURCE, version="9.0.0", repo_url="https://github.com/other/moviepilot-plugins"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id="DemoPlugin",
|
||||
identity=_identity(TrustedPluginSourceType.THIRD_PARTY, THIRD_PARTY_SOURCE),
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
|
||||
assert result.status is PluginSelectionStatus.SELECTED
|
||||
assert result.candidate is not None
|
||||
assert result.candidate.source_key == THIRD_PARTY_SOURCE
|
||||
assert result.candidate.plugin_version == "1.0.0"
|
||||
|
||||
|
||||
def test_same_source_prefers_generation_then_version() -> None:
|
||||
"""同源候选先按运行代际,再在同代内按声明版本选择。"""
|
||||
inventory = _inventory(
|
||||
MarketRead.present(
|
||||
"market-a",
|
||||
(
|
||||
_online(THIRD_PARTY_SOURCE, generation="v2", version="9.0.0"),
|
||||
_online(THIRD_PARTY_SOURCE, generation="v3", version="1.0.0"),
|
||||
_online(THIRD_PARTY_SOURCE, generation="v3", version="2.0.0"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
result = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id="DemoPlugin",
|
||||
identity=_identity(TrustedPluginSourceType.THIRD_PARTY, THIRD_PARTY_SOURCE),
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
|
||||
assert len(inventory.candidates_for("demoplugin")) == 3
|
||||
assert result.candidate is not None
|
||||
assert result.candidate.package_generation == "v3"
|
||||
assert result.candidate.plugin_version == "2.0.0"
|
||||
|
||||
|
||||
def test_partial_market_failure_blocks_unique_third_party_tofu() -> None:
|
||||
"""部分市场失败时即使当前可见一个第三方,也不能证明其唯一。"""
|
||||
inventory = _inventory(
|
||||
MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),
|
||||
MarketRead.failure("market-b", "timeout"),
|
||||
)
|
||||
|
||||
result = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
|
||||
assert inventory.complete is False
|
||||
assert inventory.can_use_for_tofu is False
|
||||
assert result.status is PluginSelectionStatus.INCOMPLETE
|
||||
|
||||
|
||||
def test_partial_inventory_expectations_never_authorize_tofu() -> None:
|
||||
"""缺少任一预期维度时,快照不能证明第三方来源唯一。"""
|
||||
reads = (MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),)
|
||||
markets_only = CandidateInventory(
|
||||
reads,
|
||||
expected_markets=("market-a", "market-b"),
|
||||
)
|
||||
generations_only = CandidateInventory(
|
||||
reads,
|
||||
expected_generations=("v3",),
|
||||
)
|
||||
|
||||
assert markets_only.complete is False
|
||||
assert markets_only.can_use_for_tofu is False
|
||||
assert generations_only.complete is False
|
||||
assert generations_only.can_use_for_tofu is False
|
||||
|
||||
|
||||
def test_local_scan_failure_blocks_automatic_selection_but_explicit_source_continues() -> None:
|
||||
"""本地扫描失败时自动路径闭锁,管理员明确选在线来源仍可继续。"""
|
||||
inventory = CandidateInventory(
|
||||
(
|
||||
MarketRead.present(
|
||||
"market-a",
|
||||
(_online(THIRD_PARTY_SOURCE),),
|
||||
),
|
||||
),
|
||||
local_read=LocalCandidateRead.failure("local repository unavailable"),
|
||||
)
|
||||
|
||||
automatic = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
explicit = select_plugin_candidate(
|
||||
inventory,
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
requested_source_key=THIRD_PARTY_SOURCE,
|
||||
explicit_source=True,
|
||||
)
|
||||
|
||||
assert automatic.status is PluginSelectionStatus.INCOMPLETE
|
||||
assert explicit.status is PluginSelectionStatus.SELECTED
|
||||
assert explicit.candidate is not None
|
||||
assert explicit.candidate.source_key == THIRD_PARTY_SOURCE
|
||||
|
||||
|
||||
def test_non_explicit_source_hint_cannot_bypass_local_state() -> None:
|
||||
"""兼容来源参数不能替换本地载荷,也不能绕过本地读取失败闭锁。"""
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?path=/private/plugins",
|
||||
package_generation="v3",
|
||||
plugin_version="2.0.0-dev",
|
||||
)
|
||||
identity = _identity(
|
||||
TrustedPluginSourceType.THIRD_PARTY,
|
||||
THIRD_PARTY_SOURCE,
|
||||
)
|
||||
with_local = select_plugin_candidate(
|
||||
_inventory(
|
||||
MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),
|
||||
local=(local,),
|
||||
),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
identity=identity,
|
||||
requested_source_key=THIRD_PARTY_SOURCE,
|
||||
explicit_source=False,
|
||||
)
|
||||
failed_local_read = select_plugin_candidate(
|
||||
CandidateInventory(
|
||||
(MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),)),),
|
||||
local_read=LocalCandidateRead.failure("local repository unavailable"),
|
||||
),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
identity=identity,
|
||||
requested_source_key=THIRD_PARTY_SOURCE,
|
||||
explicit_source=False,
|
||||
)
|
||||
|
||||
assert with_local.status is PluginSelectionStatus.SELECTED
|
||||
assert with_local.candidate is local
|
||||
assert failed_local_read.status is PluginSelectionStatus.INCOMPLETE
|
||||
|
||||
|
||||
def test_uninstalled_unique_and_multiple_sources_are_distinct() -> None:
|
||||
"""未安装插件允许完整快照中的唯一来源,多来源必须返回冲突。"""
|
||||
unique = select_plugin_candidate(
|
||||
_inventory(MarketRead.present("market-a", (_online(THIRD_PARTY_SOURCE),))),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
conflict = select_plugin_candidate(
|
||||
_inventory(
|
||||
MarketRead.present(
|
||||
"market-a",
|
||||
(_online(THIRD_PARTY_SOURCE), _online(OTHER_SOURCE)),
|
||||
),
|
||||
),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
|
||||
assert unique.status is PluginSelectionStatus.SELECTED
|
||||
assert conflict.status is PluginSelectionStatus.CONFLICT
|
||||
assert set(conflict.conflict_source_keys) == {THIRD_PARTY_SOURCE, OTHER_SOURCE}
|
||||
|
||||
|
||||
def test_official_candidate_is_selectable_and_local_projection_hides_path() -> None:
|
||||
"""官方来源可正常选择,本地公共投影不能泄漏仓库路径或 metadata。"""
|
||||
official = select_plugin_candidate(
|
||||
_inventory(
|
||||
MarketRead.present(
|
||||
"official-market",
|
||||
(_online(
|
||||
OFFICIAL_SOURCE,
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
repo_url="https://github.com/jxxghp/moviepilot-plugins",
|
||||
),),
|
||||
),
|
||||
),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
local = PluginLocalCandidate(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?path=/private/secret/plugins",
|
||||
package_generation="v3",
|
||||
plugin_version="3.0.0",
|
||||
dto={"path": "/private/secret/plugins"},
|
||||
)
|
||||
|
||||
local_result = select_plugin_candidate(
|
||||
_inventory(MarketRead.present("official-market", ()), local=(local,)),
|
||||
plugin_id="DemoPlugin",
|
||||
generations=("v3", "v2", "v1"),
|
||||
)
|
||||
|
||||
assert official.status is PluginSelectionStatus.SELECTED
|
||||
assert official.candidate is not None
|
||||
assert official.candidate.source_type is TrustedPluginSourceType.OFFICIAL
|
||||
assert local.payload_source_type is PluginPayloadSourceType.LOCAL
|
||||
assert local.source_type is PluginPayloadSourceType.LOCAL
|
||||
assert local.source_key is None
|
||||
assert local_result.candidate is local
|
||||
public = local_result.public_dict()
|
||||
assert "/private/secret/plugins" not in str(public)
|
||||
assert "repo_url" not in public["candidate"]
|
||||
@@ -1,9 +1,31 @@
|
||||
"""插件市场同步服务用例。"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.gateway import PluginInstallGateway
|
||||
from app.application.plugin.identity import (
|
||||
PluginBindingBasis,
|
||||
PluginIdentity,
|
||||
PluginPayloadSourceType,
|
||||
TrustedPluginSourceType,
|
||||
)
|
||||
from app.application.plugin.install import PluginInstallResult
|
||||
from app.application.plugin.lifecycle import plugin_lifecycle
|
||||
from app.application.plugin.source import (
|
||||
CandidateInventory,
|
||||
MarketRead,
|
||||
PluginMarketCandidate,
|
||||
)
|
||||
from app.runtime.config import global_vars
|
||||
from app.runtime.extensions.plugin.sync import PluginSyncService
|
||||
from app.startup.initializers import plugins as plugins_initializer
|
||||
|
||||
REPO_URL = "https://github.com/jxxghp/MoviePilot-Plugins"
|
||||
|
||||
|
||||
def test_market_sync_keeps_install_rollback_enabled() -> None:
|
||||
@@ -24,9 +46,191 @@ def test_market_sync_keeps_install_rollback_enabled() -> None:
|
||||
merge_plugins=lambda items, *_args: items,
|
||||
plugin_exists=lambda *_args: False,
|
||||
install=install,
|
||||
report=Mock(),
|
||||
log=Mock(),
|
||||
)
|
||||
|
||||
assert service.sync() == [plugin.id]
|
||||
install.assert_called_once_with(plugin.id, plugin.repo_url, False)
|
||||
install.assert_called_once_with(plugin.id, None, False, None)
|
||||
|
||||
|
||||
def test_market_sync_restores_trusted_online_payload_after_local_source_removed() -> None:
|
||||
"""本地高版本来源消失后,启动同步仍恢复已绑定的在线载荷。"""
|
||||
plugin = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.2.0",
|
||||
system_version_compatible=False,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
installed_plugins=lambda: [plugin.id],
|
||||
online_plugins=lambda: [plugin],
|
||||
local_plugins=lambda: [],
|
||||
merge_plugins=lambda items, *_args: items,
|
||||
plugin_exists=lambda *_args: True,
|
||||
install=install,
|
||||
log=Mock(),
|
||||
)
|
||||
|
||||
assert service.sync(
|
||||
online_restore_plugins={"demoplugin"},
|
||||
) == [plugin.id]
|
||||
install.assert_called_once_with(plugin.id, None, False, None)
|
||||
|
||||
|
||||
def test_market_sync_keeps_active_local_payload_when_candidate_still_exists() -> None:
|
||||
"""本地候选仍存在时,不应被启动在线恢复覆盖。"""
|
||||
online = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url=REPO_URL,
|
||||
plugin_name="Demo",
|
||||
plugin_version="1.2.0",
|
||||
system_version_compatible=True,
|
||||
)
|
||||
local = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url="local://DemoPlugin?package_version=v3",
|
||||
plugin_name="Demo Local",
|
||||
plugin_version="9.9.10",
|
||||
system_version_compatible=True,
|
||||
)
|
||||
install = Mock(return_value=(True, ""))
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
installed_plugins=lambda: [online.id],
|
||||
online_plugins=lambda: [online],
|
||||
local_plugins=lambda: [local],
|
||||
merge_plugins=lambda items, *_args: [online],
|
||||
plugin_exists=lambda *_args: True,
|
||||
install=install,
|
||||
log=Mock(),
|
||||
)
|
||||
|
||||
assert service.sync(online_restore_plugins={"demoplugin"}) == []
|
||||
install.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_market_sync_reuses_startup_lease_through_real_gateway(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""启动自动安装跨线程进入 Gateway 时必须复用同一个 startup lease。"""
|
||||
competing_repo_url = "https://github.com/example/MoviePilot-Plugins"
|
||||
plugin = SimpleNamespace(
|
||||
id="DemoPlugin",
|
||||
repo_url=competing_repo_url,
|
||||
plugin_name="Demo",
|
||||
plugin_version="9.0.0",
|
||||
system_version_compatible=True,
|
||||
)
|
||||
official_candidate = PluginMarketCandidate(
|
||||
plugin_id=plugin.id,
|
||||
source_key="github:jxxghp/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
repo_url=REPO_URL,
|
||||
package_generation="v3",
|
||||
plugin_version="1.1.0",
|
||||
dto={"v3": True},
|
||||
)
|
||||
competing_candidate = PluginMarketCandidate(
|
||||
plugin_id=plugin.id,
|
||||
source_key="github:example/moviepilot-plugins",
|
||||
source_type=TrustedPluginSourceType.THIRD_PARTY,
|
||||
repo_url=competing_repo_url,
|
||||
package_generation="v3",
|
||||
plugin_version=plugin.plugin_version,
|
||||
dto={"v3": True},
|
||||
)
|
||||
inventory = CandidateInventory((
|
||||
MarketRead.present(
|
||||
REPO_URL,
|
||||
(official_candidate,),
|
||||
package_generation="v3",
|
||||
),
|
||||
MarketRead.present(
|
||||
competing_repo_url,
|
||||
(competing_candidate,),
|
||||
package_generation="v3",
|
||||
),
|
||||
))
|
||||
identity = PluginIdentity(
|
||||
plugin_id=plugin.id,
|
||||
normalized_plugin_id="demoplugin",
|
||||
trusted_source_type=TrustedPluginSourceType.OFFICIAL,
|
||||
trusted_source_key="github:jxxghp/moviepilot-plugins",
|
||||
binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT,
|
||||
payload_source_type=PluginPayloadSourceType.LOCAL,
|
||||
payload_source_key=None,
|
||||
declared_version="9.9.10",
|
||||
package_generation="v3",
|
||||
system_version=None,
|
||||
supports_v3=True,
|
||||
supports_v3t=None,
|
||||
payload_receipt="sha256:" + "0" * 64,
|
||||
revision=1,
|
||||
created_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
|
||||
updated_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
|
||||
bound_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
|
||||
payload_applied_at=datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
executor = AsyncMock()
|
||||
executor.execute.return_value = PluginInstallResult(success=True)
|
||||
gateway = PluginInstallGateway(
|
||||
inventory=AsyncMock(return_value=inventory),
|
||||
identity=AsyncMock(return_value=identity),
|
||||
candidate_compatibility=lambda _candidate: (True, ""),
|
||||
executor=executor,
|
||||
clock=lambda: datetime(2026, 8, 25, 12, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
global_vars,
|
||||
"CURRENT_EVENT_LOOP",
|
||||
asyncio.get_running_loop(),
|
||||
)
|
||||
|
||||
def install(
|
||||
plugin_id: str,
|
||||
repo_url: str | None,
|
||||
force: bool,
|
||||
startup_token: object | None,
|
||||
) -> tuple[bool, str]:
|
||||
"""复用生产同步包装层,把线程池安装提交回宿主事件循环。"""
|
||||
return plugins_initializer._run_plugin_install_sync(
|
||||
gateway,
|
||||
plugin_id=plugin_id,
|
||||
repo_url=repo_url,
|
||||
package_version="v3",
|
||||
release_version=None,
|
||||
force=force,
|
||||
local_sync=False,
|
||||
explicit_source=False,
|
||||
startup_token=startup_token,
|
||||
)
|
||||
|
||||
service = PluginSyncService(
|
||||
frozen=lambda: False,
|
||||
installed_plugins=lambda: [plugin.id],
|
||||
online_plugins=lambda: [plugin],
|
||||
local_plugins=lambda: [],
|
||||
merge_plugins=lambda items, *_args: items,
|
||||
plugin_exists=lambda *_args: True,
|
||||
install=install,
|
||||
log=Mock(),
|
||||
)
|
||||
|
||||
async with plugin_lifecycle.hold_startup() as startup_token:
|
||||
synced = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
service.sync,
|
||||
startup_token,
|
||||
online_restore_plugins={"demoplugin"},
|
||||
),
|
||||
timeout=2,
|
||||
)
|
||||
|
||||
assert synced == [plugin.id]
|
||||
executor.execute.assert_awaited_once()
|
||||
admission = executor.execute.await_args.kwargs["admission"]
|
||||
assert admission.candidate.repo_url == REPO_URL
|
||||
|
||||
@@ -117,6 +117,42 @@ def test_failed_write_keeps_committed_snapshot(monkeypatch):
|
||||
assert oper.get(key) == "old"
|
||||
|
||||
|
||||
def test_update_atomically_commits_related_records_and_snapshot() -> None:
|
||||
"""关联记录与最终配置值必须在同一事务成功后一起可见。"""
|
||||
key = _unique_key()
|
||||
related_key = _unique_key()
|
||||
oper = _fresh_oper()
|
||||
oper.set(key, ["ExistingPlugin"])
|
||||
|
||||
def mutation(session, current):
|
||||
session.add(SystemConfig(key=related_key, value={"phase": "committed"}))
|
||||
return "done", [*current, "DemoPlugin"]
|
||||
|
||||
assert oper.update_atomically(key, mutation) == "done"
|
||||
assert oper.get(key) == ["ExistingPlugin", "DemoPlugin"]
|
||||
assert _stored_config(key).value == ["ExistingPlugin", "DemoPlugin"]
|
||||
assert _stored_config(related_key).value == {"phase": "committed"}
|
||||
|
||||
|
||||
def test_update_atomically_keeps_snapshot_when_related_write_fails() -> None:
|
||||
"""关联写失败时配置数据库值和内存快照都保持最近提交状态。"""
|
||||
key = _unique_key()
|
||||
related_key = _unique_key()
|
||||
oper = _fresh_oper()
|
||||
oper.set(key, ["ExistingPlugin"])
|
||||
|
||||
def mutation(session, _current):
|
||||
session.add(SystemConfig(key=related_key, value=True))
|
||||
raise RuntimeError("related write failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="related write failed"):
|
||||
oper.update_atomically(key, mutation)
|
||||
|
||||
assert oper.get(key) == ["ExistingPlugin"]
|
||||
assert _stored_config(key).value == ["ExistingPlugin"]
|
||||
assert _stored_config(related_key) is None
|
||||
|
||||
|
||||
def test_increment_serializes_concurrent_counter_updates(monkeypatch):
|
||||
"""并发递增系统计数时不应丢失更新。"""
|
||||
oper = object.__new__(SystemConfigOper)
|
||||
|
||||
Reference in New Issue
Block a user