mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
refactor: 推进后端分层架构治理
This commit is contained in:
@@ -19,6 +19,37 @@ prepare_backend()
|
||||
from app.testing.network_guard import block_real_network # noqa: E402,F401
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configure_plugin_system_services():
|
||||
"""为绕过完整启动流程的单元测试装配真实插件系统适配器。"""
|
||||
from app.adapters.external.market import (
|
||||
PluginHelper,
|
||||
VERSION_BACKWARD_COMPATIBLE_FLAGS,
|
||||
)
|
||||
from app.adapters.external.plugin.client import PluginMarketClient
|
||||
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
from app.runtime.extensions.plugin.system import (
|
||||
PluginSystemServices,
|
||||
configure_plugin_system,
|
||||
reset_plugin_system,
|
||||
)
|
||||
|
||||
helper = PluginHelper()
|
||||
configure_plugin_system(PluginSystemServices(
|
||||
market=PluginMarketClient(helper),
|
||||
package=PluginPackageManager(helper),
|
||||
dependency=PluginDependencyInstaller(helper),
|
||||
compatible_flags=lambda flag: (
|
||||
[flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, [])
|
||||
if flag else []
|
||||
),
|
||||
frozen=lambda: False,
|
||||
))
|
||||
yield
|
||||
reset_plugin_system()
|
||||
|
||||
|
||||
class DbHarness:
|
||||
"""真实数据库会话的测试载具。
|
||||
|
||||
|
||||
+6930
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"generated_at": "2026-08-17T15:05:56.902649+00:00",
|
||||
"platform": "macOS-26.5.2-arm64-arm-64bit",
|
||||
"python": "3.12.6",
|
||||
"repeat": 3,
|
||||
"targets": {
|
||||
"app.startup.lifecycle": {
|
||||
"loaded_module_count": 1779,
|
||||
"max_ms": 968.467,
|
||||
"median_ms": 968.335,
|
||||
"min_ms": 960.971,
|
||||
"samples_ms": [
|
||||
968.335,
|
||||
968.467,
|
||||
960.971
|
||||
]
|
||||
},
|
||||
"app.factory": {
|
||||
"loaded_module_count": 1791,
|
||||
"max_ms": 1026.357,
|
||||
"median_ms": 1019.56,
|
||||
"min_ms": 997.442,
|
||||
"samples_ms": [
|
||||
1019.56,
|
||||
1026.357,
|
||||
997.442
|
||||
]
|
||||
},
|
||||
"app.main": {
|
||||
"loaded_module_count": 1933,
|
||||
"max_ms": 1091.202,
|
||||
"median_ms": 1083.385,
|
||||
"min_ms": 1076.762,
|
||||
"samples_ms": [
|
||||
1091.202,
|
||||
1076.762,
|
||||
1083.385
|
||||
]
|
||||
}
|
||||
},
|
||||
"lifecycle": {
|
||||
"scope": "isolated no-op component callbacks; no plugin/network/database I/O",
|
||||
"modes": {
|
||||
"normal": {
|
||||
"samples": [
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 14,
|
||||
"startup_ms": 0.55,
|
||||
"full_lifespan_ms": 0.621,
|
||||
"stage_ms": {
|
||||
"HTTP 基础能力": 0.071,
|
||||
"领域依赖装配": 0.035,
|
||||
"数据库引擎预热": 0.029,
|
||||
"数据库连接预算": 0.026,
|
||||
"路由": 0.023,
|
||||
"模块服务": 0.021,
|
||||
"插件备份恢复": 0.021,
|
||||
"插件": 0.02,
|
||||
"定时器": 0.023,
|
||||
"监控器": 0.022,
|
||||
"待处理整理回放": 0.024,
|
||||
"命令服务": 0.023,
|
||||
"工作流": 0.022
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 14,
|
||||
"startup_ms": 0.548,
|
||||
"full_lifespan_ms": 0.613,
|
||||
"stage_ms": {
|
||||
"HTTP 基础能力": 0.075,
|
||||
"领域依赖装配": 0.035,
|
||||
"数据库引擎预热": 0.028,
|
||||
"数据库连接预算": 0.028,
|
||||
"路由": 0.026,
|
||||
"模块服务": 0.022,
|
||||
"插件备份恢复": 0.023,
|
||||
"插件": 0.023,
|
||||
"定时器": 0.021,
|
||||
"监控器": 0.019,
|
||||
"待处理整理回放": 0.024,
|
||||
"命令服务": 0.023,
|
||||
"工作流": 0.022
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "normal",
|
||||
"enabled_component_count": 14,
|
||||
"startup_ms": 0.549,
|
||||
"full_lifespan_ms": 0.616,
|
||||
"stage_ms": {
|
||||
"HTTP 基础能力": 0.07,
|
||||
"领域依赖装配": 0.034,
|
||||
"数据库引擎预热": 0.027,
|
||||
"数据库连接预算": 0.028,
|
||||
"路由": 0.026,
|
||||
"模块服务": 0.023,
|
||||
"插件备份恢复": 0.024,
|
||||
"插件": 0.023,
|
||||
"定时器": 0.021,
|
||||
"监控器": 0.019,
|
||||
"待处理整理回放": 0.024,
|
||||
"命令服务": 0.023,
|
||||
"工作流": 0.022
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.549,
|
||||
"median_full_lifespan_ms": 0.616,
|
||||
"enabled_component_count": 14
|
||||
},
|
||||
"safe": {
|
||||
"samples": [
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 6,
|
||||
"startup_ms": 0.399,
|
||||
"full_lifespan_ms": 0.46,
|
||||
"stage_ms": {
|
||||
"HTTP 基础能力": 0.069,
|
||||
"领域依赖装配": 0.035,
|
||||
"数据库引擎预热": 0.027,
|
||||
"数据库连接预算": 0.029,
|
||||
"路由": 0.024,
|
||||
"模块服务": 0.026
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 6,
|
||||
"startup_ms": 0.382,
|
||||
"full_lifespan_ms": 0.443,
|
||||
"stage_ms": {
|
||||
"HTTP 基础能力": 0.065,
|
||||
"领域依赖装配": 0.031,
|
||||
"数据库引擎预热": 0.025,
|
||||
"数据库连接预算": 0.025,
|
||||
"路由": 0.022,
|
||||
"模块服务": 0.023
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
},
|
||||
{
|
||||
"mode": "safe",
|
||||
"enabled_component_count": 6,
|
||||
"startup_ms": 0.416,
|
||||
"full_lifespan_ms": 0.482,
|
||||
"stage_ms": {
|
||||
"HTTP 基础能力": 0.074,
|
||||
"领域依赖装配": 0.034,
|
||||
"数据库引擎预热": 0.028,
|
||||
"数据库连接预算": 0.027,
|
||||
"路由": 0.025,
|
||||
"模块服务": 0.025
|
||||
},
|
||||
"threads_before": 2,
|
||||
"threads_started": 2,
|
||||
"threads_after": 2,
|
||||
"tasks_before": 1,
|
||||
"tasks_started": 2,
|
||||
"tasks_after": 1,
|
||||
"database_connections_started": 0
|
||||
}
|
||||
],
|
||||
"median_startup_ms": 0.399,
|
||||
"median_full_lifespan_ms": 0.46,
|
||||
"enabled_component_count": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,8 +283,8 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None:
|
||||
return_value=plugin_helper,
|
||||
),
|
||||
patch(
|
||||
"app.agent.tools.impl._plugin_tool_utils.reload_plugin_runtime",
|
||||
) as reload_runtime,
|
||||
"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),
|
||||
@@ -309,11 +309,15 @@ def test_install_plugin_runtime_reloads_in_threadpool() -> None:
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://example.com/market",
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert len(calls) == 2
|
||||
assert calls[0][0] == "plugin"
|
||||
assert calls[0][1] == reload_runtime
|
||||
assert calls[0][1] == plugin_manager.reload_plugin
|
||||
assert calls[0][2] == ("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:
|
||||
|
||||
+129
-3
@@ -4,7 +4,10 @@ from typing import Any
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from starlette.responses import Response as StarletteResponse
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from app.api.response import (
|
||||
@@ -683,7 +686,7 @@ def test_openapi_success_models_have_no_implicit_empty_nested_schemas():
|
||||
|
||||
|
||||
def test_plugin_routes_only_register_v1(monkeypatch):
|
||||
"""插件动态路由只应注册 v1 地址并由应用统一路由类处理。"""
|
||||
"""插件动态路由只注册 v1 地址,并显式绕过主程序响应路由。"""
|
||||
from app.application import plugins
|
||||
|
||||
class FakeApp:
|
||||
@@ -691,11 +694,14 @@ def test_plugin_routes_only_register_v1(monkeypatch):
|
||||
|
||||
def __init__(self):
|
||||
self.routes = []
|
||||
self.route_options = []
|
||||
self.openapi_schema = None
|
||||
self.router = self
|
||||
|
||||
def add_api_route(self, **kwargs):
|
||||
"""记录新增的路由路径。"""
|
||||
self.routes.append(SimpleNamespace(path=kwargs["path"]))
|
||||
self.route_options.append(kwargs)
|
||||
|
||||
def setup(self):
|
||||
"""模拟 FastAPI 路由重建。"""
|
||||
@@ -722,6 +728,7 @@ def test_plugin_routes_only_register_v1(monkeypatch):
|
||||
assert [route.path for route in fake_app.routes] == [
|
||||
"/api/v1/plugin/DemoPlugin/health"
|
||||
]
|
||||
assert fake_app.route_options[0]["route_class_override"] is APIRoute
|
||||
|
||||
plugins._update_plugin_api_routes("DemoPlugin", action="remove")
|
||||
assert fake_app.routes == []
|
||||
@@ -739,8 +746,8 @@ def test_response_router_uses_response_route_class():
|
||||
assert isinstance(router.routes[0], ResponseAPIRoute)
|
||||
|
||||
|
||||
def test_dynamic_route_without_annotation_uses_recursive_json_model():
|
||||
"""动态插件未声明模型时应以 OpenAPI 可递归展示的 JSON 类型约束 data。"""
|
||||
def test_dynamic_host_route_without_annotation_uses_recursive_json_model():
|
||||
"""主应用动态路由未声明模型时仍应使用统一响应模型。"""
|
||||
app = FastAPI()
|
||||
app.router.route_class = ResponseAPIRoute
|
||||
|
||||
@@ -755,6 +762,125 @@ def test_dynamic_route_without_annotation_uses_recursive_json_model():
|
||||
assert generic_args == (JsonData,)
|
||||
|
||||
|
||||
def build_plugin_api_app(monkeypatch) -> FastAPI:
|
||||
"""构造覆盖插件自由返回类型的动态路由测试应用。"""
|
||||
from app.application import plugins
|
||||
|
||||
class PluginPayload(BaseModel):
|
||||
"""插件自行声明的响应模型。"""
|
||||
|
||||
ok: bool
|
||||
|
||||
def dict_endpoint() -> dict[str, bool]:
|
||||
"""返回插件自定义字典。"""
|
||||
return {"ok": True}
|
||||
|
||||
def model_endpoint() -> PluginPayload:
|
||||
"""返回插件自定义模型。"""
|
||||
return PluginPayload(ok=True)
|
||||
|
||||
def response_endpoint() -> JSONResponse:
|
||||
"""返回插件自定义状态码和响应头。"""
|
||||
return JSONResponse(
|
||||
{"accepted": True},
|
||||
status_code=202,
|
||||
headers={"X-Plugin-Response": "yes"},
|
||||
)
|
||||
|
||||
async def stream_endpoint() -> StreamingResponse:
|
||||
"""返回插件自定义事件流。"""
|
||||
async def stream_source():
|
||||
"""生成插件测试事件。"""
|
||||
yield "data: plugin\n\n"
|
||||
|
||||
return StreamingResponse(stream_source(), media_type="text/event-stream")
|
||||
|
||||
def empty_endpoint() -> StarletteResponse:
|
||||
"""返回插件自定义空响应。"""
|
||||
return StarletteResponse(status_code=204)
|
||||
|
||||
class FakePluginManager:
|
||||
"""返回覆盖插件响应边界的路由声明。"""
|
||||
|
||||
def get_plugin_apis(self, plugin_id):
|
||||
"""返回测试插件 API。"""
|
||||
assert plugin_id == "DemoPlugin"
|
||||
common = {"methods": ["GET"], "allow_anonymous": True}
|
||||
return [
|
||||
{
|
||||
**common,
|
||||
"path": "/DemoPlugin/dict",
|
||||
"endpoint": dict_endpoint,
|
||||
},
|
||||
{
|
||||
**common,
|
||||
"path": "/DemoPlugin/model",
|
||||
"endpoint": model_endpoint,
|
||||
"response_model": PluginPayload,
|
||||
},
|
||||
{
|
||||
**common,
|
||||
"path": "/DemoPlugin/response",
|
||||
"endpoint": response_endpoint,
|
||||
},
|
||||
{
|
||||
**common,
|
||||
"path": "/DemoPlugin/stream",
|
||||
"endpoint": stream_endpoint,
|
||||
"response_model": None,
|
||||
},
|
||||
{
|
||||
**common,
|
||||
"path": "/DemoPlugin/empty",
|
||||
"endpoint": empty_endpoint,
|
||||
"status_code": 204,
|
||||
"response_model": None,
|
||||
},
|
||||
]
|
||||
|
||||
app = FastAPI()
|
||||
app.router.route_class = ResponseAPIRoute
|
||||
monkeypatch.setattr(plugins, "_api_app", app)
|
||||
monkeypatch.setattr(plugins, "PluginManager", FakePluginManager)
|
||||
plugins._update_plugin_api_routes("DemoPlugin", action="add")
|
||||
return app
|
||||
|
||||
|
||||
async def test_plugin_dynamic_routes_preserve_raw_runtime_responses(monkeypatch):
|
||||
"""插件动态 API 应完整保留自行选择的响应体、状态码和流。"""
|
||||
app = build_plugin_api_app(monkeypatch)
|
||||
|
||||
async with make_client(app) as client:
|
||||
dict_response = await client.get("/api/v1/plugin/DemoPlugin/dict")
|
||||
model_response = await client.get("/api/v1/plugin/DemoPlugin/model")
|
||||
native_response = await client.get("/api/v1/plugin/DemoPlugin/response")
|
||||
stream_response = await client.get("/api/v1/plugin/DemoPlugin/stream")
|
||||
empty_response = await client.get("/api/v1/plugin/DemoPlugin/empty")
|
||||
|
||||
assert dict_response.json() == {"ok": True}
|
||||
assert model_response.json() == {"ok": True}
|
||||
assert native_response.status_code == 202
|
||||
assert native_response.json() == {"accepted": True}
|
||||
assert native_response.headers["X-Plugin-Response"] == "yes"
|
||||
assert stream_response.text == "data: plugin\n\n"
|
||||
assert empty_response.status_code == 204
|
||||
assert empty_response.content == b""
|
||||
|
||||
|
||||
def test_plugin_dynamic_routes_keep_plugin_openapi_model_raw(monkeypatch):
|
||||
"""插件声明的模型应直接进入 OpenAPI,不得套入主程序 Response。"""
|
||||
app = build_plugin_api_app(monkeypatch)
|
||||
|
||||
operation = app.openapi()["paths"]["/api/v1/plugin/DemoPlugin/model"]["get"]
|
||||
response_schema = operation["responses"]["200"]["content"][
|
||||
"application/json"
|
||||
]["schema"]
|
||||
|
||||
assert response_schema == {
|
||||
"$ref": "#/components/schemas/PluginPayload",
|
||||
}
|
||||
|
||||
|
||||
def test_dynamic_bare_response_uses_recursive_json_without_double_wrapping():
|
||||
"""动态插件声明裸 Response 时应补齐递归 JSON 类型且不重复封装。"""
|
||||
router = ResponseAPIRouter()
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.schemas.types import ChainEventType, EventType
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture"
|
||||
|
||||
|
||||
def test_architecture_contract_baselines_match_current_source():
|
||||
"""宿主依赖图和公开运行契约变化必须显式刷新基线。"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/architecture/baseline.py", "--check"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_official_plugin_baseline_records_external_source():
|
||||
"""官方插件快照必须绑定独立仓提交,且不得引用宿主插件副本。"""
|
||||
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert baseline["source"]["repository"] == "MoviePilot-Plugins"
|
||||
assert len(baseline["source"]["head"]) == 40
|
||||
assert baseline["source"]["roots"] == ["plugins.v2", "plugins.v3"]
|
||||
assert all(
|
||||
not path.startswith("app/plugins/")
|
||||
for contract in (*baseline["imports"].values(), *baseline["hooks"].values())
|
||||
for path in contract["files"]
|
||||
)
|
||||
assert all(
|
||||
not path.startswith("app/plugins/")
|
||||
for path in baseline["api_routes"]
|
||||
)
|
||||
|
||||
|
||||
def test_official_discovery_plugins_explicitly_keep_host_page_envelope():
|
||||
"""宿主探索页消费的官方插件 API 不得依赖动态路由隐式包装。"""
|
||||
baseline_path = BASELINE_ROOT / "official-plugin-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
routes = {
|
||||
(path, route["path"]): route
|
||||
for path, file_routes in baseline["api_routes"].items()
|
||||
for route in file_routes
|
||||
}
|
||||
|
||||
expected_paths = {
|
||||
("plugins.v3/imdbsource/__init__.py", "/imdb-discover"),
|
||||
("plugins.v3/imdbsource/__init__.py", "/imdb-top-250"),
|
||||
("plugins.v3/imdbsource/__init__.py", "/imdb-trending"),
|
||||
("plugins.v3/imdbsource/__init__.py", "/trending"),
|
||||
("plugins.v3/tvdbdiscover/__init__.py", "/tvdb_discover"),
|
||||
}
|
||||
for route_key in expected_paths:
|
||||
route = routes[route_key]
|
||||
assert route["response_model"] == "schemas.Response[List[schemas.MediaInfo]]"
|
||||
assert route["endpoint_return"] == "schemas.Response[List[schemas.MediaInfo]]"
|
||||
|
||||
|
||||
def test_startup_performance_baseline_records_all_cold_import_targets():
|
||||
"""启动性能基线必须包含关键入口的可比较冷导入采样。"""
|
||||
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert baseline["repeat"] >= 3
|
||||
assert set(baseline["targets"]) == {
|
||||
"app.startup.lifecycle",
|
||||
"app.factory",
|
||||
"app.main",
|
||||
}
|
||||
for contract in baseline["targets"].values():
|
||||
assert len(contract["samples_ms"]) == baseline["repeat"]
|
||||
assert contract["min_ms"] <= contract["median_ms"] <= contract["max_ms"]
|
||||
assert contract["loaded_module_count"] > 0
|
||||
|
||||
|
||||
def test_startup_performance_baseline_records_normal_and_safe_lifecycle_resources():
|
||||
"""非功能基线必须同时记录正常/安全模式和隔离资源增量。"""
|
||||
baseline_path = BASELINE_ROOT / "startup-performance-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
lifecycle = baseline["lifecycle"]
|
||||
|
||||
assert "no-op" in lifecycle["scope"]
|
||||
assert set(lifecycle["modes"]) == {"normal", "safe"}
|
||||
normal = lifecycle["modes"]["normal"]
|
||||
safe = lifecycle["modes"]["safe"]
|
||||
assert normal["enabled_component_count"] > safe["enabled_component_count"]
|
||||
for mode in (normal, safe):
|
||||
assert len(mode["samples"]) == baseline["repeat"]
|
||||
for sample in mode["samples"]:
|
||||
assert sample["threads_after"] == sample["threads_before"]
|
||||
assert sample["tasks_after"] == sample["tasks_before"]
|
||||
assert sample["database_connections_started"] == 0
|
||||
assert sample["stage_ms"]
|
||||
|
||||
|
||||
def test_schema_export_manifest_matches_current_modules():
|
||||
"""Schema 公开符号或冲突来源变化必须显式刷新生成清单。"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/schema/exports.py", "--check"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_schema_root_import_does_not_eagerly_load_schema_graph():
|
||||
"""仅导入 schema 根包时不得加载任一业务 schema 子模块。"""
|
||||
script = """
|
||||
import sys
|
||||
import app.schemas
|
||||
|
||||
loaded = sorted(
|
||||
name
|
||||
for name in sys.modules
|
||||
if name.startswith('app.schemas.') and name != 'app.schemas.exports'
|
||||
)
|
||||
assert not loaded, loaded
|
||||
assert len(app.schemas.__all__) >= 400
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_event_contract_baseline_covers_every_public_event_enum() -> None:
|
||||
"""事件生产者/消费者快照必须覆盖全部广播和链式事件枚举。"""
|
||||
baseline_path = BASELINE_ROOT / "runtime-contract-baseline.json"
|
||||
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
||||
events = baseline["events"]
|
||||
expected = {
|
||||
*(f"EventType.{member.name}" for member in EventType),
|
||||
*(f"ChainEventType.{member.name}" for member in ChainEventType),
|
||||
}
|
||||
|
||||
assert set(events["events"]) == expected
|
||||
assert events["event_count"] == len(expected)
|
||||
assert events["producer_count"] > 0
|
||||
assert events["consumer_count"] > 0
|
||||
@@ -66,6 +66,17 @@ RETIRED_CANONICAL_FILES = (
|
||||
"app/adapters/network/rss.py",
|
||||
"app/adapters/network/sites.pyi",
|
||||
)
|
||||
PLUGIN_COMPONENT_ROOTS = (
|
||||
"app/adapters/external/plugin",
|
||||
"app/adapters/system/plugin",
|
||||
"app/application/plugin",
|
||||
"app/runtime/extensions/plugin",
|
||||
)
|
||||
PLUGIN_LEGACY_ABI_NAMES = {
|
||||
"MoviePilotServerHelper",
|
||||
"PluginHelper",
|
||||
"PluginManager",
|
||||
}
|
||||
FORBIDDEN_IMPORT_PREFIXES = {
|
||||
"app.foundation": (
|
||||
"app.adapters",
|
||||
@@ -282,6 +293,86 @@ def test_host_code_does_not_import_legacy_roots():
|
||||
assert violations == {}
|
||||
|
||||
|
||||
def test_plugin_components_do_not_reexport_legacy_abi_names():
|
||||
"""新插件组件只提供 canonical 能力,不得复制旧 Helper、Manager 或 Oper 导出。"""
|
||||
violations: list[str] = []
|
||||
for root in PLUGIN_COMPONENT_ROOTS:
|
||||
for path in (PROJECT_ROOT / root).rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if node.name == "__getattr__" or node.name in PLUGIN_LEGACY_ABI_NAMES:
|
||||
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}")
|
||||
elif isinstance(node, ast.ClassDef):
|
||||
if node.name in PLUGIN_LEGACY_ABI_NAMES or node.name.endswith("Oper"):
|
||||
violations.append(f"{path.relative_to(PROJECT_ROOT)}:{node.name}")
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
for alias in node.names:
|
||||
is_legacy_name = (
|
||||
alias.name in PLUGIN_LEGACY_ABI_NAMES
|
||||
or alias.name.endswith("Oper")
|
||||
)
|
||||
is_private = bool(alias.asname and alias.asname.startswith("_"))
|
||||
if is_legacy_name and not is_private:
|
||||
violations.append(
|
||||
f"{path.relative_to(PROJECT_ROOT)}:{alias.name}"
|
||||
)
|
||||
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||
names = {
|
||||
target.id
|
||||
for target in targets
|
||||
if isinstance(target, ast.Name)
|
||||
}
|
||||
forbidden = {
|
||||
name
|
||||
for name in names
|
||||
if name == "__all__"
|
||||
or name in PLUGIN_LEGACY_ABI_NAMES
|
||||
or name.endswith("Oper")
|
||||
}
|
||||
violations.extend(
|
||||
f"{path.relative_to(PROJECT_ROOT)}:{name}"
|
||||
for name in sorted(forbidden)
|
||||
)
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_host_code_uses_precise_schema_modules():
|
||||
"""宿主不得重新依赖 schema 聚合入口或星号导出。"""
|
||||
violations: list[str] = []
|
||||
for path in APP_ROOT.rglob("*.py"):
|
||||
relative = path.relative_to(APP_ROOT)
|
||||
if relative.parts[0] in {"plugins", "schemas"}:
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "app":
|
||||
if any(alias.name == "schemas" for alias in node.names):
|
||||
violations.append(str(relative))
|
||||
break
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "app.schemas":
|
||||
violations.append(str(relative))
|
||||
break
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_database_internals_do_not_import_db_facades():
|
||||
"""DB 子模块必须依赖具体实现文件,不得回流到包级兼容入口。"""
|
||||
violations: list[str] = []
|
||||
for path in (APP_ROOT / "db").rglob("*.py"):
|
||||
if path.name == "__init__.py":
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
|
||||
if any(
|
||||
isinstance(node, ast.ImportFrom)
|
||||
and node.module in {"app.db", "app.db.models"}
|
||||
for node in ast.walk(tree)
|
||||
):
|
||||
violations.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_migrated_modules_are_not_in_import_cycles():
|
||||
"""任何 canonical 迁移模块都不得进入完整应用依赖图的环。"""
|
||||
modules = _discover_modules()
|
||||
|
||||
@@ -10,10 +10,13 @@ sys.modules.setdefault("transmission_rpc", ModuleType("transmission_rpc"))
|
||||
setattr(sys.modules["transmission_rpc"], "File", object)
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.application.chain.context import ChainRuntimeContext
|
||||
from app.schemas import RateLimitExceededException
|
||||
|
||||
|
||||
class _LimitedModule:
|
||||
"""模拟始终触发本地限流的宿主模块。"""
|
||||
|
||||
def get_name(self):
|
||||
"""
|
||||
返回测试模块名称。
|
||||
@@ -40,18 +43,31 @@ class _LimitedModule:
|
||||
|
||||
|
||||
class ChainRateLimitTest(unittest.TestCase):
|
||||
"""验证模块限流异常的兼容传播和告警语义。"""
|
||||
|
||||
def _build_chain(self):
|
||||
"""
|
||||
构造隔离的 ChainBase,避免依赖真实模块和插件运行状态。
|
||||
"""
|
||||
chain = ChainBase()
|
||||
limited_module = _LimitedModule()
|
||||
chain.pluginmanager = Mock()
|
||||
chain.pluginmanager.get_plugin_modules.return_value = {}
|
||||
chain.modulemanager = Mock()
|
||||
chain.modulemanager.get_running_modules.return_value = [limited_module]
|
||||
chain.messagehelper = Mock()
|
||||
chain.eventmanager = Mock()
|
||||
plugin_manager = Mock()
|
||||
plugin_manager.get_plugin_modules.return_value = {}
|
||||
module_manager = Mock()
|
||||
module_manager.get_running_modules.return_value = [limited_module]
|
||||
message_helper = Mock()
|
||||
event_manager = Mock()
|
||||
chain = ChainBase(
|
||||
ChainRuntimeContext(
|
||||
module_manager=module_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
event_manager=event_manager,
|
||||
message_oper=Mock(),
|
||||
message_helper=message_helper,
|
||||
file_cache=Mock(),
|
||||
async_file_cache=Mock(),
|
||||
message_queue_factory=lambda _callback: Mock(),
|
||||
)
|
||||
)
|
||||
return chain
|
||||
|
||||
def test_rate_limit_is_not_reported_as_system_error(self):
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Chain 运行上下文注入和无参兼容 provider 测试。"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.application.chain.context import ChainRuntimeContext
|
||||
from app.application.chain import context as chain_context
|
||||
from app.chain import ChainBase
|
||||
|
||||
|
||||
def _context() -> ChainRuntimeContext:
|
||||
"""构造不连接数据库、不启动线程的最小 Chain 上下文。"""
|
||||
return ChainRuntimeContext(
|
||||
module_manager=Mock(),
|
||||
plugin_manager=Mock(),
|
||||
event_manager=Mock(),
|
||||
message_oper=Mock(),
|
||||
message_helper=Mock(),
|
||||
file_cache=Mock(),
|
||||
async_file_cache=Mock(),
|
||||
message_queue_factory=Mock(return_value=Mock()),
|
||||
)
|
||||
|
||||
|
||||
def test_chain_accepts_explicit_runtime_context() -> None:
|
||||
"""新代码应能显式注入最小运行时依赖而不创建真实管理器。"""
|
||||
context = _context()
|
||||
|
||||
chain = ChainBase(context)
|
||||
|
||||
assert chain.modulemanager is context.module_manager
|
||||
assert chain.pluginmanager is context.plugin_manager
|
||||
assert chain.eventmanager is context.event_manager
|
||||
assert chain.messagehelper is context.message_helper
|
||||
context.message_queue_factory.assert_called_once_with(chain.run_module)
|
||||
|
||||
|
||||
def test_no_arg_chain_uses_compatibility_context_provider(monkeypatch) -> None:
|
||||
"""V3 兼容期内无参 Chain() 应从组合根 provider 获取相同上下文。"""
|
||||
context = _context()
|
||||
provider = Mock(return_value=context)
|
||||
monkeypatch.setattr(chain_context, "_context_provider", provider)
|
||||
|
||||
chain = ChainBase()
|
||||
|
||||
provider.assert_called_once_with()
|
||||
assert chain.modulemanager is context.module_manager
|
||||
assert chain.pluginmanager is context.plugin_manager
|
||||
@@ -0,0 +1,79 @@
|
||||
"""阶段 4 六个重点 Chain 的纵向切片数量守卫。"""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.message import MessageChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.transfer import TransferChain
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("chain_type", "method_tokens"),
|
||||
[
|
||||
(
|
||||
SubscribeChain,
|
||||
{
|
||||
"exists": "_subscription_query",
|
||||
"get_subscribe_by_source": "_subscription_query",
|
||||
"has_music_subscribe": "_subscription_query",
|
||||
},
|
||||
),
|
||||
(
|
||||
SearchChain,
|
||||
{
|
||||
"save_last_search_params": "_search_state",
|
||||
"last_search_params": "_search_state",
|
||||
"last_search_results": "_search_state",
|
||||
},
|
||||
),
|
||||
(
|
||||
TransferChain,
|
||||
{
|
||||
"put_to_queue": "_transfer_queue_service",
|
||||
"remove_from_queue": "_transfer_queue_service",
|
||||
"get_queue_tasks": "_transfer_queue_service",
|
||||
},
|
||||
),
|
||||
(
|
||||
DownloadChain,
|
||||
{
|
||||
"downloading": "_download_task_service",
|
||||
"set_downloading": "_download_task_service",
|
||||
"remove_downloading": "_download_task_service",
|
||||
},
|
||||
),
|
||||
(
|
||||
MediaChain,
|
||||
{
|
||||
"normalize_music_candidates": "MusicCatalogService",
|
||||
"search_music": "_music_catalog",
|
||||
"async_search_music": "_music_catalog",
|
||||
},
|
||||
),
|
||||
(
|
||||
MessageChain,
|
||||
{
|
||||
"remote_clear_session": "_message_session_service",
|
||||
"remote_stop_agent": "_message_session_service",
|
||||
"remote_session_status": "_message_session_service",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_key_chain_keeps_three_application_service_slices(
|
||||
chain_type: type,
|
||||
method_tokens: dict[str, str],
|
||||
) -> None:
|
||||
"""每个重点 Chain 至少三个公开方法必须继续委托窄应用服务。"""
|
||||
assert len(method_tokens) >= 3
|
||||
missing = []
|
||||
for method_name, service_token in method_tokens.items():
|
||||
method = getattr(chain_type, method_name)
|
||||
if service_token not in inspect.getsource(method):
|
||||
missing.append(f"{chain_type.__name__}.{method_name}->{service_token}")
|
||||
assert missing == []
|
||||
@@ -147,7 +147,9 @@ class DataCleanupChainTest(unittest.TestCase):
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with self._cleanup_settings(), patch("app.scheduler.SessionFactory", self.SessionFactory):
|
||||
with self._cleanup_settings(), patch(
|
||||
"app.application.maintenance.SessionFactory", self.SessionFactory
|
||||
):
|
||||
report = SchedulerChain().cleanup(batch_size=1)
|
||||
|
||||
self.assertEqual(report["tables"]["message"]["deleted"], 3)
|
||||
@@ -184,7 +186,9 @@ class DataCleanupChainTest(unittest.TestCase):
|
||||
)
|
||||
db.commit()
|
||||
|
||||
with self._cleanup_settings(), patch("app.scheduler.SessionFactory", self.SessionFactory):
|
||||
with self._cleanup_settings(), patch(
|
||||
"app.application.maintenance.SessionFactory", self.SessionFactory
|
||||
):
|
||||
report = SchedulerChain().cleanup(batch_size=10)
|
||||
|
||||
self.assertEqual(report["tables"]["transferhistory"]["deleted"], 0)
|
||||
@@ -204,7 +208,7 @@ class DataCleanupChainTest(unittest.TestCase):
|
||||
db.commit()
|
||||
|
||||
with self._cleanup_settings(DATA_CLEANUP_ENABLE=False), patch(
|
||||
"app.scheduler.SessionFactory", self.SessionFactory
|
||||
"app.application.maintenance.SessionFactory", self.SessionFactory
|
||||
):
|
||||
report = SchedulerChain().cleanup(batch_size=10)
|
||||
|
||||
@@ -233,7 +237,7 @@ class DataCleanupChainTest(unittest.TestCase):
|
||||
db.commit()
|
||||
|
||||
with self._cleanup_settings(DATA_CLEANUP_MESSAGE_DAYS=7), patch(
|
||||
"app.scheduler.SessionFactory", self.SessionFactory
|
||||
"app.application.maintenance.SessionFactory", self.SessionFactory
|
||||
):
|
||||
report = SchedulerChain().cleanup(batch_size=10)
|
||||
|
||||
@@ -271,7 +275,7 @@ class DataCleanupChainTest(unittest.TestCase):
|
||||
db.commit()
|
||||
|
||||
with self._cleanup_settings(DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS=0), patch(
|
||||
"app.scheduler.SessionFactory", self.SessionFactory
|
||||
"app.application.maintenance.SessionFactory", self.SessionFactory
|
||||
):
|
||||
report = SchedulerChain().cleanup(batch_size=10)
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import ast
|
||||
from contextlib import nullcontext
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.maintenance import CleanupPolicy, DataCleanupService
|
||||
from app.scheduler import SchedulerChain
|
||||
|
||||
|
||||
class FakeCleanupRepository:
|
||||
"""记录维护用例调用,并可模拟单表失败。"""
|
||||
|
||||
def __init__(self, *, failing_table: str | None = None) -> None:
|
||||
"""保存故障表名并初始化调用记录。"""
|
||||
self.failing_table = failing_table
|
||||
self.calls: list[str] = []
|
||||
self._message_results = iter((2, 1, 0))
|
||||
|
||||
def session(self):
|
||||
"""返回无需真实数据库的上下文。"""
|
||||
return nullcontext(object())
|
||||
|
||||
def _delete(self, name: str) -> int:
|
||||
"""记录删除调用并按配置模拟结果或异常。"""
|
||||
self.calls.append(name)
|
||||
if name == self.failing_table:
|
||||
raise RuntimeError("boom")
|
||||
if name == "message":
|
||||
return next(self._message_results)
|
||||
return 0
|
||||
|
||||
def delete_messages(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟消息删除。"""
|
||||
return self._delete("message")
|
||||
|
||||
def delete_download_history(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟下载历史删除。"""
|
||||
return self._delete("downloadhistory")
|
||||
|
||||
def delete_download_orphans(self, db, limit: int) -> int:
|
||||
"""模拟孤儿文件删除。"""
|
||||
return self._delete("downloadfiles")
|
||||
|
||||
def delete_site_userdata(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟站点用户数据删除。"""
|
||||
return self._delete("siteuserdata")
|
||||
|
||||
def delete_transfer_history(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟整理历史删除。"""
|
||||
return self._delete("transferhistory")
|
||||
|
||||
def delete_download_failures(self, db, cutoff: str, limit: int) -> int:
|
||||
"""模拟下载失败记录删除。"""
|
||||
return self._delete("downloadfailure")
|
||||
|
||||
|
||||
def _policy(**overrides) -> CleanupPolicy:
|
||||
"""构造所有表默认启用的测试策略。"""
|
||||
values = {
|
||||
"enabled": True,
|
||||
"message_days": 1,
|
||||
"download_history_days": 1,
|
||||
"site_userdata_days": 1,
|
||||
"transfer_history_days": 1,
|
||||
"download_failure_days": 1,
|
||||
}
|
||||
values.update(overrides)
|
||||
return CleanupPolicy(**values)
|
||||
|
||||
|
||||
def test_cleanup_service_owns_batching_report_and_progress() -> None:
|
||||
"""应用服务应独立完成分批循环、报告和进度语义。"""
|
||||
repository = FakeCleanupRepository()
|
||||
progress = MagicMock()
|
||||
service = DataCleanupService(
|
||||
repository=repository,
|
||||
policy_reader=_policy,
|
||||
clock=lambda: datetime(2026, 8, 17, 12, 0, 0),
|
||||
)
|
||||
|
||||
report = service.execute(batch_size=2, progress_callback=progress)
|
||||
|
||||
assert report["started_at"] == "2026-08-17 12:00:00"
|
||||
assert report["tables"]["message"]["deleted"] == 3
|
||||
assert report["tables"]["message"]["batches"] == 2
|
||||
assert report["total_deleted"] == 3
|
||||
assert repository.calls == [
|
||||
"message",
|
||||
"message",
|
||||
"message",
|
||||
"downloadhistory",
|
||||
"downloadfiles",
|
||||
"siteuserdata",
|
||||
"transferhistory",
|
||||
"downloadfailure",
|
||||
]
|
||||
assert progress.call_args.kwargs["value"] == 100
|
||||
|
||||
|
||||
def test_cleanup_service_finishes_other_tables_before_raising_partial_failure() -> None:
|
||||
"""单表失败应被汇总,后续表仍继续处理,再按旧契约抛出异常。"""
|
||||
repository = FakeCleanupRepository(failing_table="downloadhistory")
|
||||
service = DataCleanupService(
|
||||
repository=repository,
|
||||
policy_reader=_policy,
|
||||
clock=lambda: datetime(2026, 8, 17, 12, 0, 0),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="downloadhistory: boom"):
|
||||
service.execute(batch_size=2)
|
||||
|
||||
assert repository.calls[-1] == "downloadfailure"
|
||||
|
||||
|
||||
def test_scheduler_cleanup_is_a_compatibility_delegate() -> None:
|
||||
"""旧 SchedulerChain 入口应原样转发参数和返回值。"""
|
||||
service = MagicMock()
|
||||
service.execute.return_value = {"enabled": True}
|
||||
progress = MagicMock()
|
||||
|
||||
with patch("app.scheduler.build_cleanup_service", return_value=service):
|
||||
result = SchedulerChain().cleanup(batch_size=7, progress_callback=progress)
|
||||
|
||||
assert result == {"enabled": True}
|
||||
service.execute.assert_called_once_with(
|
||||
batch_size=7,
|
||||
progress_callback=progress,
|
||||
)
|
||||
|
||||
|
||||
def test_scheduler_does_not_reclaim_database_cleanup_ownership() -> None:
|
||||
"""调度模块不得重新导入清理模型或数据库会话。"""
|
||||
scheduler_path = Path(__file__).parents[1] / "app" / "scheduler.py"
|
||||
tree = ast.parse(scheduler_path.read_text(encoding="utf-8"))
|
||||
imports = {
|
||||
node.module
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.ImportFrom) and node.module
|
||||
}
|
||||
|
||||
assert "app.db.session" not in imports
|
||||
assert not any(module.startswith("app.db.models") for module in imports)
|
||||
@@ -16,8 +16,11 @@ from pathlib import Path
|
||||
import pytest
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
import app.db.models # noqa: F401 确保全部模型完成注册
|
||||
from app.db import Base
|
||||
from app.db.models import load_all_models
|
||||
|
||||
|
||||
load_all_models()
|
||||
from app.db.base import get_id_column
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
@@ -2,19 +2,24 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api.endpoints import download as download_endpoint
|
||||
from app.schemas.token import TokenPayload
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
|
||||
|
||||
class DownloadPathsEndpointTest(unittest.TestCase):
|
||||
"""验证下载路径接口生成可直接提交给下载接口的路径数据。"""
|
||||
|
||||
def test_paths_returns_api_ready_save_paths(self):
|
||||
"""配置的本地和远程目录应转换为完整下载路径响应。"""
|
||||
mocked_dirs = [
|
||||
download_endpoint.schemas.TransferDirectoryConf(
|
||||
TransferDirectoryConf(
|
||||
name="电影目录",
|
||||
priority=1,
|
||||
storage="local",
|
||||
download_path="/downloads/movies",
|
||||
media_type="movie",
|
||||
),
|
||||
download_endpoint.schemas.TransferDirectoryConf(
|
||||
TransferDirectoryConf(
|
||||
name="动漫远程目录",
|
||||
priority=2,
|
||||
storage="rclone",
|
||||
@@ -25,7 +30,7 @@ class DownloadPathsEndpointTest(unittest.TestCase):
|
||||
]
|
||||
|
||||
with patch.object(download_endpoint.DirectoryHelper, "get_download_dirs", return_value=mocked_dirs):
|
||||
ret = download_endpoint.paths(_=download_endpoint.schemas.TokenPayload())
|
||||
ret = download_endpoint.paths(_=TokenPayload())
|
||||
|
||||
self.assertEqual(len(ret), 2)
|
||||
self.assertEqual(ret[0].name, "电影目录")
|
||||
@@ -45,7 +50,8 @@ class DownloadPathsEndpointTest(unittest.TestCase):
|
||||
self.assertEqual(ret[1].media_category, "动漫")
|
||||
|
||||
def test_paths_returns_empty_list_when_unconfigured(self):
|
||||
"""未配置目录时接口应返回空列表。"""
|
||||
with patch.object(download_endpoint.DirectoryHelper, "get_download_dirs", return_value=[]):
|
||||
ret = download_endpoint.paths(_=download_endpoint.schemas.TokenPayload())
|
||||
ret = download_endpoint.paths(_=TokenPayload())
|
||||
|
||||
self.assertEqual(ret, [])
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""下载任务应用服务测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.application.download.tasks import DownloadTaskService
|
||||
|
||||
|
||||
def test_download_task_service_enriches_history_and_controls_task():
|
||||
"""下载任务查询应附加历史媒体信息,控制方法只转发规范参数。"""
|
||||
torrent = SimpleNamespace(hash="hash", media=None)
|
||||
history = SimpleNamespace(
|
||||
media_source="tmdb",
|
||||
media_id="123",
|
||||
type="电影",
|
||||
title="测试电影",
|
||||
seasons=[1],
|
||||
episodes=[2],
|
||||
poster="poster",
|
||||
image="backdrop",
|
||||
torrent_site="站点",
|
||||
userid=1,
|
||||
username="alice",
|
||||
)
|
||||
calls = []
|
||||
service = DownloadTaskService(
|
||||
list_torrents=lambda **kwargs: [torrent],
|
||||
get_history_by_hashes=lambda hashes: {hashes[0]: history},
|
||||
start_torrents=lambda **kwargs: calls.append(("start", kwargs)) or True,
|
||||
stop_torrents=lambda **kwargs: calls.append(("stop", kwargs)) or True,
|
||||
remove_torrents=lambda **kwargs: calls.append(("remove", kwargs)) or True,
|
||||
)
|
||||
|
||||
assert service.downloading("qb") == [torrent]
|
||||
assert torrent.media["media_id"] == "123"
|
||||
assert torrent.username == "alice"
|
||||
assert service.set_downloading("hash", "start", "qb") is True
|
||||
assert service.set_downloading("hash", "stop", "qb") is True
|
||||
assert service.remove_downloading("hash", "qb") is True
|
||||
assert [call[0] for call in calls] == ["start", "stop", "remove"]
|
||||
|
||||
|
||||
def test_download_task_service_rejects_unknown_operation():
|
||||
"""未知操作保持旧的 False 返回语义。"""
|
||||
service = DownloadTaskService(
|
||||
list_torrents=lambda **_kwargs: [],
|
||||
get_history_by_hashes=lambda _hashes: {},
|
||||
start_torrents=lambda **_kwargs: True,
|
||||
stop_torrents=lambda **_kwargs: True,
|
||||
remove_torrents=lambda **_kwargs: True,
|
||||
)
|
||||
|
||||
assert service.set_downloading("hash", "pause") is False
|
||||
@@ -0,0 +1,128 @@
|
||||
"""事件注册、绑定、调度和错误策略组件的独立测试。"""
|
||||
|
||||
import threading
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.runtime.event.binding import (
|
||||
EventBindingResolver,
|
||||
EventHandlerBinding,
|
||||
)
|
||||
from app.runtime.event.errors import EventErrorPolicy
|
||||
from app.runtime.events import Event
|
||||
from app.schemas.types import EventType
|
||||
from app.startup.modules_initializer import get_host_event_handler_factories
|
||||
|
||||
|
||||
class _UnmanagedHandler:
|
||||
"""记录构造次数,用于证明绑定未命中时不会被总线实例化。"""
|
||||
|
||||
constructed = 0
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""记录任何非预期的隐式构造。"""
|
||||
type(self).constructed += 1
|
||||
|
||||
def handle(self, _event: Event) -> None:
|
||||
"""提供可解析的实例方法声明。"""
|
||||
|
||||
|
||||
def test_binding_miss_does_not_construct_handler_owner() -> None:
|
||||
"""resolver 未命中时只记录诊断,不能调用 owner_class()。"""
|
||||
resolvers = {}
|
||||
binding = EventBindingResolver(
|
||||
lock=threading.Lock(),
|
||||
resolvers=lambda: resolvers,
|
||||
)
|
||||
_UnmanagedHandler.constructed = 0
|
||||
|
||||
assert binding.resolve(_UnmanagedHandler.handle) is None
|
||||
assert _UnmanagedHandler.constructed == 0
|
||||
assert binding.unresolved_handlers() == (
|
||||
f"{__name__}._UnmanagedHandler.handle",
|
||||
)
|
||||
|
||||
|
||||
def test_binding_uses_explicit_resolver_instance() -> None:
|
||||
"""显式 resolver 应返回当前托管实例上的绑定方法。"""
|
||||
instance = object.__new__(_UnmanagedHandler)
|
||||
resolvers = {
|
||||
"test": lambda owner: EventHandlerBinding(
|
||||
instance=instance,
|
||||
owner_name="托管处理器",
|
||||
)
|
||||
if owner is _UnmanagedHandler
|
||||
else None
|
||||
}
|
||||
binding = EventBindingResolver(
|
||||
lock=threading.Lock(),
|
||||
resolvers=lambda: resolvers,
|
||||
)
|
||||
|
||||
method, resolved, class_name, method_name = binding.resolve(
|
||||
_UnmanagedHandler.handle
|
||||
)
|
||||
|
||||
assert method.__self__ is instance
|
||||
assert resolved.owner_name == "托管处理器"
|
||||
assert class_name == "_UnmanagedHandler"
|
||||
assert method_name == "handle"
|
||||
|
||||
|
||||
def test_system_error_failure_does_not_rebroadcast() -> None:
|
||||
"""SystemError 处理器自身失败时只能通知和日志降级,不能再次发送事件。"""
|
||||
notifier = Mock()
|
||||
emit = Mock()
|
||||
policy = EventErrorPolicy(
|
||||
notifier=lambda: notifier,
|
||||
emit_system_error=emit,
|
||||
)
|
||||
|
||||
policy.handle(
|
||||
event=Event(EventType.SystemError, {}),
|
||||
module_name="测试模块",
|
||||
class_name="BrokenHandler",
|
||||
method_name="handle",
|
||||
error=RuntimeError("broken"),
|
||||
)
|
||||
|
||||
notifier.assert_called_once()
|
||||
emit.assert_not_called()
|
||||
|
||||
|
||||
def test_regular_event_failure_emits_one_system_error() -> None:
|
||||
"""普通事件失败应生成一次结构稳定的 SystemError 载荷。"""
|
||||
emit = Mock()
|
||||
policy = EventErrorPolicy(
|
||||
notifier=lambda: None,
|
||||
emit_system_error=emit,
|
||||
)
|
||||
|
||||
policy.handle(
|
||||
event=Event(EventType.ConfigChanged, {}),
|
||||
module_name="测试模块",
|
||||
class_name="BrokenHandler",
|
||||
method_name="handle",
|
||||
error=RuntimeError("broken"),
|
||||
)
|
||||
|
||||
payload = emit.call_args.args[0]
|
||||
assert payload["type"] == "event"
|
||||
assert payload["event_type"] is EventType.ConfigChanged
|
||||
assert payload["event_handle"] == "BrokenHandler.handle"
|
||||
assert payload["error"] == "broken"
|
||||
|
||||
|
||||
def test_all_decorated_host_handler_classes_have_explicit_factories() -> None:
|
||||
"""宿主中使用事件装饰器的类必须全部由组合根 resolver 白名单接管。"""
|
||||
factories = get_host_event_handler_factories()
|
||||
|
||||
assert {owner.__name__ for owner in factories} == {
|
||||
"Command",
|
||||
"DownloadChain",
|
||||
"Scheduler",
|
||||
"ScrapingChain",
|
||||
"SearchChain",
|
||||
"SiteChain",
|
||||
"SubscribeChain",
|
||||
"WorkflowChain",
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.history import (
|
||||
DownloadHistoryMutationCommand,
|
||||
TransferHistoryMutationCommand,
|
||||
)
|
||||
|
||||
|
||||
def _transfer_command(*, history=None, delete_result=True, commit_error=None):
|
||||
"""构造可观察整理历史事务和外部副作用的命令。"""
|
||||
repository = Mock()
|
||||
repository.get.return_value = history
|
||||
download_repository = Mock()
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit.side_effect = commit_error
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"download_repository": download_repository,
|
||||
"unit_of_work": unit_of_work,
|
||||
"file_item_factory": lambda payload: SimpleNamespace(**payload),
|
||||
"delete_media_file": Mock(return_value=delete_result),
|
||||
"publish_download_file_deleted": Mock(),
|
||||
"clear_failures": Mock(),
|
||||
}
|
||||
return TransferHistoryMutationCommand(**dependencies), dependencies
|
||||
|
||||
|
||||
def _history():
|
||||
"""构造包含源和目标文件信息的整理历史快照。"""
|
||||
return SimpleNamespace(
|
||||
id=7,
|
||||
src="/downloads/demo.mkv",
|
||||
src_storage="local",
|
||||
download_hash="abc",
|
||||
src_fileitem={"path": "/downloads/demo.mkv"},
|
||||
dest_fileitem={"path": "/media/demo.mkv"},
|
||||
)
|
||||
|
||||
|
||||
def test_download_history_delete_rolls_back_commit_failure():
|
||||
"""下载历史提交失败时必须回滚请求级事务。"""
|
||||
repository = Mock()
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit.side_effect = RuntimeError("commit failed")
|
||||
command = DownloadHistoryMutationCommand(
|
||||
repository=repository,
|
||||
unit_of_work=unit_of_work,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
command.delete(8)
|
||||
|
||||
repository.stage_delete_history.assert_called_once_with(8)
|
||||
unit_of_work.rollback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_transfer_source_delete_failure_keeps_database_unchanged():
|
||||
"""源文件删除失败时不得删除历史或更新下载文件状态。"""
|
||||
command, dependencies = _transfer_command(
|
||||
history=_history(),
|
||||
delete_result=False,
|
||||
)
|
||||
|
||||
result = command.delete(7, delete_source=True)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "/downloads/demo.mkv 删除失败"
|
||||
dependencies["repository"].stage_delete.assert_not_called()
|
||||
dependencies["download_repository"].stage_delete_file_by_fullpath.assert_not_called()
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_delete_commits_before_event_and_retry_cleanup():
|
||||
"""整理记录提交成功后才发送文件删除事件并清理失败计数。"""
|
||||
calls = []
|
||||
command, dependencies = _transfer_command(history=_history())
|
||||
dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit")
|
||||
dependencies["publish_download_file_deleted"].side_effect = (
|
||||
lambda _payload: calls.append("event")
|
||||
)
|
||||
dependencies["clear_failures"].side_effect = lambda *_args: calls.append("clear")
|
||||
|
||||
result = command.delete(
|
||||
7,
|
||||
delete_source=True,
|
||||
delete_destination=True,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
dependencies["download_repository"].stage_delete_file_by_fullpath.assert_called_once_with(
|
||||
"/downloads/demo.mkv"
|
||||
)
|
||||
dependencies["repository"].stage_delete.assert_called_once_with(7)
|
||||
assert calls == ["commit", "event", "clear"]
|
||||
|
||||
|
||||
def test_transfer_commit_failure_suppresses_event_and_retry_cleanup():
|
||||
"""数据库提交失败时不得发布已删除事件或清除重试状态。"""
|
||||
command, dependencies = _transfer_command(
|
||||
history=_history(),
|
||||
commit_error=RuntimeError("commit failed"),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
command.delete(7, delete_source=True)
|
||||
|
||||
dependencies["unit_of_work"].rollback.assert_called_once_with()
|
||||
dependencies["publish_download_file_deleted"].assert_not_called()
|
||||
dependencies["clear_failures"].assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_truncate_uses_single_transaction():
|
||||
"""清空整理历史只暂存一次并统一提交。"""
|
||||
command, dependencies = _transfer_command()
|
||||
|
||||
result = command.truncate()
|
||||
|
||||
assert result.success is True
|
||||
dependencies["repository"].stage_truncate.assert_called_once_with()
|
||||
dependencies["unit_of_work"].commit.assert_called_once_with()
|
||||
@@ -270,12 +270,27 @@ def test_physical_modules_resolve_moved_symbols_without_reverse_imports():
|
||||
schema_media = importlib.import_module("app.schemas.media")
|
||||
transfer_schema = importlib.import_module("app.schemas.transfer")
|
||||
legacy_transfer = importlib.import_module("app.sdk._legacy.transfer")
|
||||
history_schema = importlib.import_module("app.schemas.history")
|
||||
system_schema = importlib.import_module("app.schemas.system")
|
||||
tmdb_schema = importlib.import_module("app.schemas.tmdb")
|
||||
types_schema = importlib.import_module("app.schemas.types")
|
||||
agent_schema = importlib.import_module("app.schemas.agent")
|
||||
sdk_logging = importlib.import_module("app.sdk.logging")
|
||||
legacy_logging = importlib.import_module("app.log")
|
||||
runtime_logging = importlib.import_module("app.runtime.log")
|
||||
schemas_package = importlib.import_module("app.schemas")
|
||||
|
||||
assert domain_media.build_media_key is schema_media.build_media_key
|
||||
assert domain_media.resolve_media_identity is schema_media.resolve_media_identity
|
||||
assert transfer_schema.TransferTask is legacy_transfer.TransferTask
|
||||
assert transfer_schema.TransferQueue is legacy_transfer.TransferQueue
|
||||
assert transfer_schema.DownloadHistory is history_schema.DownloadHistory
|
||||
assert transfer_schema.TransferDirectoryConf is system_schema.TransferDirectoryConf
|
||||
assert transfer_schema.TmdbEpisode is tmdb_schema.TmdbEpisode
|
||||
assert transfer_schema.MediaType is types_schema.MediaType
|
||||
assert agent_schema.ReplyMode is types_schema.ReplyMode
|
||||
assert sdk_logging.LoggerManager is runtime_logging.LoggerManager
|
||||
assert legacy_logging.LoggerManager is runtime_logging.LoggerManager
|
||||
assert schemas_package.TransferTask is legacy_transfer.TransferTask
|
||||
assert schemas_package.TransferQueue is legacy_transfer.TransferQueue
|
||||
|
||||
@@ -360,6 +375,22 @@ def test_symbol_alias_manifest_covers_all_moved_public_symbols():
|
||||
assert set(SYMBOL_ALIASES["app.schemas.transfer"]) == {
|
||||
"TransferTask",
|
||||
"TransferQueue",
|
||||
"DownloadHistory",
|
||||
"TransferDirectoryConf",
|
||||
"TmdbEpisode",
|
||||
"MediaType",
|
||||
}
|
||||
assert set(SYMBOL_ALIASES["app.schemas.agent"]) == {"ReplyMode"}
|
||||
assert set(SYMBOL_ALIASES["app.sdk.logging"]) == {
|
||||
"CustomFormatter",
|
||||
"LogConfigModel",
|
||||
"LogEntry",
|
||||
"LogSettings",
|
||||
"LoggerManager",
|
||||
"NonBlockingFileHandler",
|
||||
"configure_log_settings",
|
||||
"configure_log_writer",
|
||||
"log_settings",
|
||||
}
|
||||
assert set(SYMBOL_ALIASES["app.schemas.types"]) == {
|
||||
"MessageChannel",
|
||||
|
||||
@@ -113,6 +113,147 @@ def test_lifespan_continues_after_each_shutdown_owner_failure(
|
||||
_assert_completed_once(step)
|
||||
|
||||
|
||||
def test_lifespan_normal_mode_starts_full_runtime(monkeypatch):
|
||||
"""正常模式必须初始化插件及后台服务,并在退出时逐项停止。"""
|
||||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||||
|
||||
async def run_lifespan():
|
||||
async with lifecycle.lifespan(FastAPI()):
|
||||
pass
|
||||
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
lifecycle.init_modules.assert_awaited_once_with()
|
||||
for name in (
|
||||
"init_plugins",
|
||||
"init_scheduler",
|
||||
"init_monitor",
|
||||
"replay_pending_transfers",
|
||||
"init_command",
|
||||
"init_workflow",
|
||||
):
|
||||
getattr(lifecycle, name).assert_called_once_with()
|
||||
for step in shutdown_steps.values():
|
||||
_assert_completed_once(step)
|
||||
|
||||
|
||||
def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
|
||||
"""安全模式只启动基础模块,并跳过插件及可选后台服务。"""
|
||||
shutdown_steps = _patch_lifespan(monkeypatch)
|
||||
monkeypatch.setattr(lifecycle.settings, "MOVIEPILOT_SAFE_MODE", True)
|
||||
|
||||
async def run_lifespan():
|
||||
async with lifecycle.lifespan(FastAPI()):
|
||||
pass
|
||||
|
||||
asyncio.run(run_lifespan())
|
||||
|
||||
lifecycle.init_modules.assert_awaited_once_with()
|
||||
for name in (
|
||||
"init_plugins",
|
||||
"init_scheduler",
|
||||
"init_monitor",
|
||||
"replay_pending_transfers",
|
||||
"init_command",
|
||||
"init_workflow",
|
||||
):
|
||||
getattr(lifecycle, name).assert_not_called()
|
||||
for name in (
|
||||
"backup_plugins",
|
||||
"stop_workflow",
|
||||
"stop_command",
|
||||
"stop_monitor",
|
||||
"stop_scheduler",
|
||||
"stop_plugins",
|
||||
):
|
||||
shutdown_steps[name].assert_not_called()
|
||||
_assert_completed_once(shutdown_steps["stop_modules"])
|
||||
_assert_completed_once(shutdown_steps["close_http"])
|
||||
_assert_completed_once(shutdown_steps["logger"])
|
||||
|
||||
|
||||
def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
|
||||
"""组件清单应显式冻结依赖、模式、启动/关闭顺序和超时预算。"""
|
||||
app = FastAPI()
|
||||
normal = lifecycle.get_lifecycle_manifest(app, safe_mode=False)
|
||||
safe = lifecycle.get_lifecycle_manifest(app, safe_mode=True)
|
||||
|
||||
normal_start = [
|
||||
item["name"]
|
||||
for item in sorted(
|
||||
(entry for entry in normal if entry["start_order"] is not None),
|
||||
key=lambda entry: entry["start_order"],
|
||||
)
|
||||
]
|
||||
normal_stop = [
|
||||
item["name"]
|
||||
for item in sorted(
|
||||
(entry for entry in normal if entry["stop_order"] is not None),
|
||||
key=lambda entry: entry["stop_order"],
|
||||
)
|
||||
]
|
||||
safe_names = {item["name"] for item in safe}
|
||||
|
||||
assert normal_start == [
|
||||
"HTTP 基础能力",
|
||||
"领域依赖装配",
|
||||
"数据库引擎预热",
|
||||
"数据库连接预算",
|
||||
"路由",
|
||||
"模块服务",
|
||||
"插件备份恢复",
|
||||
"插件",
|
||||
"定时器",
|
||||
"监控器",
|
||||
"待处理整理回放",
|
||||
"命令服务",
|
||||
"工作流",
|
||||
]
|
||||
assert normal_stop == [
|
||||
"插件备份",
|
||||
"工作流",
|
||||
"命令服务",
|
||||
"监控器",
|
||||
"定时器",
|
||||
"插件",
|
||||
"模块服务",
|
||||
"HTTP 基础能力",
|
||||
]
|
||||
assert safe_names == {
|
||||
"HTTP 基础能力",
|
||||
"领域依赖装配",
|
||||
"数据库引擎预热",
|
||||
"数据库连接预算",
|
||||
"路由",
|
||||
"模块服务",
|
||||
}
|
||||
assert all(item["start_failure"] == "fail_fast" for item in normal)
|
||||
assert all(item["stop_failure"] == "continue" for item in normal)
|
||||
assert all(
|
||||
item["start_timeout_seconds"] or item["stop_timeout_seconds"]
|
||||
for item in normal
|
||||
)
|
||||
|
||||
|
||||
def test_startup_step_records_duration_without_changing_result(monkeypatch):
|
||||
"""启动阶段计时必须保留返回值,并输出稳定的阶段名称和毫秒耗时。"""
|
||||
perf_counter = MagicMock(side_effect=[10.0, 10.125])
|
||||
logger_info = MagicMock()
|
||||
monkeypatch.setattr(lifecycle.time, "perf_counter", perf_counter)
|
||||
monkeypatch.setattr(lifecycle.logger, "info", logger_info)
|
||||
|
||||
result = asyncio.run(
|
||||
lifecycle.run_startup_step("契约测试", lambda: "ready")
|
||||
)
|
||||
|
||||
assert result == "ready"
|
||||
logger_info.assert_called_once_with(
|
||||
"启动%s完成,耗时=%.2fms",
|
||||
"契约测试",
|
||||
125.0,
|
||||
)
|
||||
|
||||
|
||||
def test_lifespan_creates_global_async_engine_at_startup(monkeypatch):
|
||||
"""启动期必须把全局异步引擎建出来一次,让异步侧恢复 fail-fast
|
||||
|
||||
@@ -260,6 +401,13 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_asgi_and_main_entrypoints_share_the_same_app_instance():
|
||||
"""ASGI 工厂入口与主程序入口必须暴露同一个 FastAPI 实例。"""
|
||||
from app import factory, main
|
||||
|
||||
assert main.app is factory.app
|
||||
|
||||
|
||||
def test_application_does_not_start_server_after_migration_failure(monkeypatch):
|
||||
"""数据库迁移失败时不得启动 API 服务。"""
|
||||
from app import main
|
||||
|
||||
@@ -10,14 +10,20 @@ from app.agent.llm.provider import (
|
||||
LLMProviderAuthError,
|
||||
LLMProviderManager,
|
||||
)
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
_MANAGER_SINGLETON_KEY = (LLMProviderManager, (), frozenset())
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_manager_singleton():
|
||||
"""每个用例前后清理 LLMProviderManager 单例,避免缓存互相污染"""
|
||||
LLMProviderManager._instances.clear()
|
||||
previous_manager = Singleton._instances.pop(_MANAGER_SINGLETON_KEY, None)
|
||||
yield
|
||||
LLMProviderManager._instances.clear()
|
||||
Singleton._instances.pop(_MANAGER_SINGLETON_KEY, None)
|
||||
if previous_manager is not None:
|
||||
Singleton._instances[_MANAGER_SINGLETON_KEY] = previous_manager
|
||||
|
||||
|
||||
def test_bedrock_provider_registered():
|
||||
|
||||
@@ -9,14 +9,25 @@ from app.agent.llm.provider import (
|
||||
LLMProviderManager,
|
||||
PendingAuthSession,
|
||||
)
|
||||
from app.foundation.singleton import Singleton
|
||||
|
||||
|
||||
_MANAGER_SINGLETON_KEY = (LLMProviderManager, (), frozenset())
|
||||
|
||||
|
||||
class LlmProviderRegistryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
LLMProviderManager._instances.clear()
|
||||
"""隔离当前测试使用的 LLM 管理器单例,不影响其他运行时单例。"""
|
||||
self._previous_manager = Singleton._instances.pop(
|
||||
_MANAGER_SINGLETON_KEY,
|
||||
None,
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
LLMProviderManager._instances.clear()
|
||||
"""恢复测试前的 LLM 管理器单例。"""
|
||||
Singleton._instances.pop(_MANAGER_SINGLETON_KEY, None)
|
||||
if self._previous_manager is not None:
|
||||
Singleton._instances[_MANAGER_SINGLETON_KEY] = self._previous_manager
|
||||
|
||||
def test_dynamic_provider_is_exposed_from_models_dev_cache(self):
|
||||
manager = LLMProviderManager()
|
||||
|
||||
@@ -1736,10 +1736,10 @@ def test_torrent_selection_prompts_download_dir_buttons_before_download():
|
||||
request.phase = "torrent"
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_multiple_movie_download_dirs(),
|
||||
), patch.object(chain, "post_message") as post_message, patch(
|
||||
"app.chain.message.DownloadChain.download_single"
|
||||
"app.chain.interaction.DownloadChain.download_single"
|
||||
) as download_single:
|
||||
handled = chain.handle_text_interaction(
|
||||
channel=NotificationChannel.Telegram,
|
||||
@@ -1781,10 +1781,10 @@ def test_torrent_selection_skips_download_dir_when_only_one_dir_matches_media():
|
||||
request.phase = "torrent"
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_download_dirs(),
|
||||
), patch.object(chain, "post_message") as post_message, patch(
|
||||
"app.chain.message.DownloadChain.download_single",
|
||||
"app.chain.interaction.DownloadChain.download_single",
|
||||
return_value="hash",
|
||||
) as download_single:
|
||||
handled = chain.handle_text_interaction(
|
||||
@@ -1821,10 +1821,10 @@ def test_torrent_selection_skips_download_dir_when_user_has_single_dir():
|
||||
request.phase = "torrent"
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_single_download_dir(),
|
||||
), patch.object(chain, "post_message") as post_message, patch(
|
||||
"app.chain.message.DownloadChain.download_single",
|
||||
"app.chain.interaction.DownloadChain.download_single",
|
||||
return_value="hash",
|
||||
) as download_single:
|
||||
handled = chain.handle_text_interaction(
|
||||
@@ -1861,7 +1861,7 @@ def test_torrent_selection_prompts_text_download_dir_for_plain_channel():
|
||||
request.phase = "torrent"
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_multiple_movie_download_dirs(),
|
||||
), patch.object(chain, "post_message") as post_message:
|
||||
handled = chain.handle_text_interaction(
|
||||
@@ -1903,10 +1903,10 @@ def test_download_dir_callback_runs_pending_single_download_without_save_path_fo
|
||||
request.pending_download_context = context
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_multiple_movie_download_dirs(),
|
||||
), patch(
|
||||
"app.chain.message.DownloadChain.download_single",
|
||||
"app.chain.interaction.DownloadChain.download_single",
|
||||
return_value="hash",
|
||||
) as download_single:
|
||||
request.download_dirs = chain._get_download_dirs(context.media_info)
|
||||
@@ -1945,10 +1945,10 @@ def test_download_dir_callback_runs_pending_single_download_with_save_path():
|
||||
request.pending_download_context = context
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_multiple_movie_download_dirs(),
|
||||
), patch(
|
||||
"app.chain.message.DownloadChain.download_single",
|
||||
"app.chain.interaction.DownloadChain.download_single",
|
||||
return_value="hash",
|
||||
) as download_single:
|
||||
request.download_dirs = chain._get_download_dirs(context.media_info)
|
||||
@@ -1987,10 +1987,10 @@ def test_download_dir_text_reply_runs_pending_single_download_without_save_path(
|
||||
request.pending_download_context = context
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_multiple_movie_download_dirs(),
|
||||
), patch(
|
||||
"app.chain.message.DownloadChain.download_single",
|
||||
"app.chain.interaction.DownloadChain.download_single",
|
||||
return_value="hash",
|
||||
) as download_single:
|
||||
request.download_dirs = chain._get_download_dirs()
|
||||
@@ -2015,7 +2015,7 @@ def test_get_download_dirs_keeps_matching_tv_category_dir():
|
||||
context = _build_tv_context()
|
||||
|
||||
with patch(
|
||||
"app.chain.message.DirectoryHelper.get_download_dirs",
|
||||
"app.chain.interaction.DirectoryHelper.get_download_dirs",
|
||||
return_value=_build_download_dirs(),
|
||||
):
|
||||
download_dirs = chain._get_download_dirs(context.media_info)
|
||||
|
||||
@@ -119,14 +119,13 @@ def test_default_recognition_passes_empty_generic_identity() -> None:
|
||||
def test_module_dispatch_always_reaches_plugins() -> None:
|
||||
"""模块调度必须始终先执行插件模块。"""
|
||||
chain = _chain_without_init()
|
||||
chain._ChainBase__execute_plugin_modules = Mock(return_value="plugin")
|
||||
chain._ChainBase__execute_system_modules = Mock(return_value="system")
|
||||
chain._module_dispatcher = Mock()
|
||||
chain._module_dispatcher.dispatch.return_value = "plugin"
|
||||
|
||||
result = chain.run_module("search_medias", meta=MetaBase("test"))
|
||||
|
||||
assert result == "plugin"
|
||||
chain._ChainBase__execute_plugin_modules.assert_called_once()
|
||||
chain._ChainBase__execute_system_modules.assert_not_called()
|
||||
chain._module_dispatcher.dispatch.assert_called_once()
|
||||
|
||||
|
||||
def test_explicit_search_source_reaches_plugins() -> None:
|
||||
|
||||
@@ -16,8 +16,8 @@ def test_mediaserver_conf_tolerates_blank_sync_interval():
|
||||
def test_get_configs_skips_invalid_entries(monkeypatch):
|
||||
"""单条配置校验失败时应跳过该条,不影响其它服务配置的加载。"""
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.service_registry.SystemConfigOper.get",
|
||||
lambda self, key: [
|
||||
"app.runtime.extensions.service_config._service_config_reader",
|
||||
lambda key: [
|
||||
{"name": "good", "type": "emby", "enabled": True},
|
||||
"bad-format",
|
||||
{"name": "bad-type", "type": "plex", "enabled": "maybe"},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.application.messaging.session import MessageSessionService
|
||||
|
||||
|
||||
def test_message_session_service_reuses_and_refreshes_active_session():
|
||||
"""复用窗口内应返回原会话并刷新最后活动时间。"""
|
||||
now = datetime(2026, 8, 17, 12, 0, 0)
|
||||
sessions = {"user": ("session-1", now - timedelta(minutes=5))}
|
||||
service = MessageSessionService(
|
||||
sessions=sessions,
|
||||
timeout_minutes=60,
|
||||
expired_handler=Mock(),
|
||||
clock=lambda: now,
|
||||
)
|
||||
|
||||
result = service.resolve("user")
|
||||
|
||||
assert result.session_id == "session-1"
|
||||
assert result.reused is True
|
||||
assert result.inactive_minutes == 5
|
||||
assert sessions["user"] == ("session-1", now)
|
||||
|
||||
|
||||
def test_message_session_service_cleans_expired_before_creating_session():
|
||||
"""创建新会话前应释放同一映射中的全部过期 Agent 会话。"""
|
||||
now = datetime(2026, 8, 17, 12, 0, 0)
|
||||
sessions = {"old": ("session-old", now - timedelta(minutes=61))}
|
||||
expired_handler = Mock()
|
||||
service = MessageSessionService(
|
||||
sessions=sessions,
|
||||
timeout_minutes=60,
|
||||
expired_handler=expired_handler,
|
||||
clock=lambda: now,
|
||||
session_id_factory=lambda user_id, _now: f"new-{user_id}",
|
||||
)
|
||||
|
||||
result = service.resolve("new")
|
||||
|
||||
assert result.session_id == "new-new"
|
||||
assert result.reused is False
|
||||
assert "old" not in sessions
|
||||
expired_handler.assert_called_once_with("session-old", "old")
|
||||
|
||||
|
||||
def test_message_session_service_bind_and_clear_preserve_old_cleanup_contract():
|
||||
"""替换绑定时释放旧会话,显式清理只返回被移除的会话 ID。"""
|
||||
now = datetime(2026, 8, 17, 12, 0, 0)
|
||||
sessions = {"user": ("session-old", now)}
|
||||
expired_handler = Mock()
|
||||
service = MessageSessionService(
|
||||
sessions=sessions,
|
||||
timeout_minutes=60,
|
||||
expired_handler=expired_handler,
|
||||
clock=lambda: now,
|
||||
)
|
||||
|
||||
service.bind("user", "session-new")
|
||||
|
||||
expired_handler.assert_called_once_with("session-old", "user")
|
||||
assert service.get("user") == ("session-new", now)
|
||||
assert service.clear("user") == "session-new"
|
||||
assert service.clear("user") is None
|
||||
@@ -0,0 +1,209 @@
|
||||
"""模块调用调度器的同步、异步协议回归测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
|
||||
|
||||
|
||||
class _PluginCatalog:
|
||||
"""提供固定插件方法表的内存目录。"""
|
||||
|
||||
def __init__(self, modules: dict) -> None:
|
||||
"""保存测试提供的插件模块快照。"""
|
||||
self.modules = modules
|
||||
|
||||
def get_plugin_modules(self) -> dict:
|
||||
"""返回当前插件模块快照。"""
|
||||
return self.modules
|
||||
|
||||
|
||||
class _ModuleCatalog:
|
||||
"""提供固定宿主模块序列的内存目录。"""
|
||||
|
||||
def __init__(self, modules: list) -> None:
|
||||
"""保存测试提供的宿主模块。"""
|
||||
self.modules = modules
|
||||
|
||||
def get_running_modules(self, _method: str) -> list:
|
||||
"""返回所有测试模块,由调度器负责优先级排序。"""
|
||||
return list(self.modules)
|
||||
|
||||
|
||||
class _Module:
|
||||
"""实现可配置优先级和调用函数的测试宿主模块。"""
|
||||
|
||||
def __init__(self, name: str, priority: int, func: Callable) -> None:
|
||||
"""保存展示名、优先级和测试调用函数。"""
|
||||
self._name = name
|
||||
self._priority = priority
|
||||
self._func = func
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""返回测试模块展示名。"""
|
||||
return self._name
|
||||
|
||||
def get_priority(self) -> int:
|
||||
"""返回调度优先级。"""
|
||||
return self._priority
|
||||
|
||||
def execute(self, *args, **kwargs):
|
||||
"""把模块调用转发到测试函数。"""
|
||||
return self._func(*args, **kwargs)
|
||||
|
||||
|
||||
def _dispatcher(
|
||||
*,
|
||||
plugins: dict | None = None,
|
||||
modules: list | None = None,
|
||||
async_runner: Callable | None = None,
|
||||
) -> tuple[ModuleInvocationDispatcher, Mock, Mock, Mock]:
|
||||
"""构造完全内存化的调度器及错误策略替身。"""
|
||||
plugin_error = Mock()
|
||||
system_error = Mock()
|
||||
rate_error = Mock()
|
||||
|
||||
async def default_runner(func, *args, **kwargs):
|
||||
"""在测试事件循环中直接运行同步函数。"""
|
||||
return func(*args, **kwargs)
|
||||
|
||||
dispatcher = ModuleInvocationDispatcher(
|
||||
module_catalog=_ModuleCatalog(modules or []),
|
||||
plugin_catalog=_PluginCatalog(plugins or {}),
|
||||
plugin_error_handler=plugin_error,
|
||||
system_error_handler=system_error,
|
||||
rate_limit_handler=rate_error,
|
||||
async_function_runner=async_runner or default_runner,
|
||||
)
|
||||
return dispatcher, plugin_error, system_error, rate_error
|
||||
|
||||
|
||||
def test_plugin_scalar_short_circuits_system_modules() -> None:
|
||||
"""插件返回非空标量时不得继续执行宿主模块。"""
|
||||
system_call = Mock(return_value="system")
|
||||
dispatcher, _, _, _ = _dispatcher(
|
||||
plugins={("P1", "插件一"): {"execute": lambda: "plugin"}},
|
||||
modules=[_Module("系统", 10, system_call)],
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch("execute") == "plugin"
|
||||
system_call.assert_not_called()
|
||||
|
||||
|
||||
def test_list_results_merge_in_plugin_then_priority_order() -> None:
|
||||
"""列表结果应先按插件顺序合并,再按宿主优先级继续合并。"""
|
||||
calls = []
|
||||
|
||||
def result(value: str) -> Callable:
|
||||
"""生成记录调用顺序并返回单项列表的模块函数。"""
|
||||
return lambda: calls.append(value) or [value]
|
||||
|
||||
dispatcher, _, _, _ = _dispatcher(
|
||||
plugins={
|
||||
("P1", "插件一"): {"execute": result("plugin-1")},
|
||||
("P2", "插件二"): {"execute": result("plugin-2")},
|
||||
},
|
||||
modules=[
|
||||
_Module("慢模块", 20, result("system-20")),
|
||||
_Module("快模块", 10, result("system-10")),
|
||||
],
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch("execute") == [
|
||||
"plugin-1",
|
||||
"plugin-2",
|
||||
"system-10",
|
||||
"system-20",
|
||||
]
|
||||
assert calls == ["plugin-1", "plugin-2", "system-10", "system-20"]
|
||||
|
||||
|
||||
def test_system_signature_relay_passes_previous_result() -> None:
|
||||
"""单参数宿主方法应接收上一模块的非列表结果。"""
|
||||
class FirstModule:
|
||||
"""产生首个字典结果的测试模块。"""
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回测试模块名。"""
|
||||
return "第一步"
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""返回第一优先级。"""
|
||||
return 10
|
||||
|
||||
@staticmethod
|
||||
def execute() -> dict:
|
||||
"""产生首个模块结果。"""
|
||||
return {"value": 1}
|
||||
|
||||
class SecondModule:
|
||||
"""消费上一结果的测试模块。"""
|
||||
|
||||
@staticmethod
|
||||
def get_name() -> str:
|
||||
"""返回测试模块名。"""
|
||||
return "第二步"
|
||||
|
||||
@staticmethod
|
||||
def get_priority() -> int:
|
||||
"""返回第二优先级。"""
|
||||
return 20
|
||||
|
||||
@staticmethod
|
||||
def execute(previous: dict) -> dict:
|
||||
"""接收上一模块结果并生成下一结果。"""
|
||||
return {"value": previous["value"] + 1}
|
||||
|
||||
dispatcher, _, _, _ = _dispatcher(
|
||||
modules=[SecondModule(), FirstModule()]
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch("execute") == {"value": 2}
|
||||
|
||||
|
||||
def test_module_exception_uses_error_policy_and_continues() -> None:
|
||||
"""普通异常应交给错误策略,后续空结果模块仍可继续运行。"""
|
||||
def broken():
|
||||
"""模拟模块执行失败。"""
|
||||
raise RuntimeError("broken")
|
||||
|
||||
dispatcher, _, system_error, _ = _dispatcher(
|
||||
modules=[
|
||||
_Module("失败模块", 10, broken),
|
||||
_Module("后续模块", 20, lambda: "ok"),
|
||||
],
|
||||
)
|
||||
|
||||
assert dispatcher.dispatch("execute") == "ok"
|
||||
system_error.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_dispatch_awaits_coroutines_and_offloads_sync_functions() -> None:
|
||||
"""异步路径应直接等待协程,并通过注入执行器运行同步方法。"""
|
||||
offloaded = []
|
||||
|
||||
async def async_runner(func, *args, **kwargs):
|
||||
"""记录被移出事件循环的同步函数。"""
|
||||
offloaded.append(func)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
async def plugin_call():
|
||||
"""返回插件列表结果。"""
|
||||
return ["plugin"]
|
||||
|
||||
sync_module = _Module("同步模块", 10, lambda: ["system"])
|
||||
dispatcher, _, _, _ = _dispatcher(
|
||||
plugins={("P1", "插件一"): {"execute": plugin_call}},
|
||||
modules=[sync_module],
|
||||
async_runner=async_runner,
|
||||
)
|
||||
|
||||
assert await dispatcher.async_dispatch("execute") == ["plugin", "system"]
|
||||
assert offloaded == [sync_module.execute]
|
||||
@@ -525,6 +525,7 @@ from app.runtime.extensions.host_module_adapter import (
|
||||
HostModuleAdapter,
|
||||
build_host_module_registry,
|
||||
)
|
||||
from app.runtime.extensions.service_config import configure_service_config_reader
|
||||
from app.schemas import ConfigChangeEventData
|
||||
from app.schemas.types import EventType
|
||||
|
||||
@@ -578,6 +579,7 @@ def get_config(_self, key=None):
|
||||
return config_values.get(key_value)
|
||||
|
||||
SystemConfigOper.get = get_config
|
||||
configure_service_config_reader(lambda key: SystemConfigOper().get(key))
|
||||
|
||||
from app.runtime.extensions.module_manager import ModuleManager
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""模块字符串方法契约清单的架构测试。"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from app.runtime.extensions.module.contracts import (
|
||||
ModuleResultAggregation,
|
||||
get_module_method_contract,
|
||||
is_explicit_module_method,
|
||||
)
|
||||
|
||||
|
||||
RUNTIME_BASELINE = (
|
||||
Path(__file__).parent / "fixtures" / "architecture" / "runtime-contract-baseline.json"
|
||||
)
|
||||
|
||||
|
||||
def test_all_scanned_module_methods_resolve_a_contract() -> None:
|
||||
"""架构快照中的所有字符串方法都必须能解析到稳定聚合规则。"""
|
||||
payload = json.loads(RUNTIME_BASELINE.read_text(encoding="utf-8"))
|
||||
methods = payload["run_module"]["methods"]
|
||||
|
||||
assert methods
|
||||
for method in methods:
|
||||
contract = get_module_method_contract(method)
|
||||
assert contract.aggregation is ModuleResultAggregation.LEGACY
|
||||
assert contract.plugin_short_circuit is True
|
||||
|
||||
|
||||
def test_high_frequency_capability_families_are_explicit() -> None:
|
||||
"""媒体发现、识别、存储和消息族不能退回未分类 legacy 契约。"""
|
||||
expected_families = {
|
||||
"async_tmdb_discover": "tmdb",
|
||||
"async_douban_discover": "douban",
|
||||
"bangumi_info": "bangumi",
|
||||
"anilist_info": "anilist",
|
||||
"recognize_media": "media-recognition",
|
||||
"mediaserver_items": "media-server",
|
||||
"list_files": "storage",
|
||||
"finalize_message": "messaging",
|
||||
"scheduler_job": "scheduling",
|
||||
}
|
||||
|
||||
for method, family in expected_families.items():
|
||||
assert is_explicit_module_method(method)
|
||||
assert get_module_method_contract(method).family == family
|
||||
|
||||
|
||||
def test_unknown_plugin_method_keeps_legacy_compatibility() -> None:
|
||||
"""第三方插件自定义方法仍应落入开放的 legacy 调度协议。"""
|
||||
contract = get_module_method_contract("third_party_custom_method")
|
||||
|
||||
assert contract.family == "legacy"
|
||||
assert contract.supports_sync is True
|
||||
assert contract.supports_async is True
|
||||
@@ -0,0 +1,52 @@
|
||||
"""音乐目录应用服务测试。"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.application.music.catalog import MusicCatalogService
|
||||
from app.domain.context import MusicInfo
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class _Source:
|
||||
"""提供同步和异步搜索接口的音乐来源替身。"""
|
||||
|
||||
def search_music(self, _meta, limit=20):
|
||||
"""返回重复候选,验证归一化去重。"""
|
||||
return [
|
||||
MusicInfo(media_source=MediaSource.MusicBrainz, media_id="1", title="A"),
|
||||
MusicInfo(media_source=MediaSource.MusicBrainz, media_id="1", title="A"),
|
||||
][:limit]
|
||||
|
||||
async def async_search_music(self, _meta, limit=20):
|
||||
"""返回异步候选。"""
|
||||
return self.search_music(_meta, limit)
|
||||
|
||||
|
||||
def test_music_catalog_service_searches_and_deduplicates_sources():
|
||||
"""同步和异步音乐搜索都应保留来源身份并去重。"""
|
||||
service = MusicCatalogService(
|
||||
source_resolver=lambda source: _Source() if source == MediaSource.MusicBrainz else None,
|
||||
warning=lambda _message: None,
|
||||
)
|
||||
|
||||
assert len(service.search("artist title")) == 1
|
||||
assert len(asyncio.run(service.async_search("artist title"))) == 1
|
||||
|
||||
|
||||
def test_music_catalog_service_isolates_failed_source():
|
||||
"""一个来源失败不应阻断其它来源。"""
|
||||
errors = []
|
||||
|
||||
class _Broken:
|
||||
def search_music(self, *_args, **_kwargs):
|
||||
"""模拟来源错误。"""
|
||||
raise RuntimeError("broken")
|
||||
|
||||
service = MusicCatalogService(
|
||||
source_resolver=lambda _source: _Broken(),
|
||||
warning=errors.append,
|
||||
)
|
||||
|
||||
assert service.search("artist title") == []
|
||||
assert errors and "broken" in errors[0]
|
||||
@@ -0,0 +1,113 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from packaging.version import Version
|
||||
|
||||
from app.application.plugin.catalog import PluginCatalogService
|
||||
|
||||
|
||||
def _plugin(plugin_id: str, version: str, repo_url: str):
|
||||
"""构造目录合并测试使用的最小插件 DTO。"""
|
||||
return SimpleNamespace(
|
||||
id=plugin_id,
|
||||
plugin_version=version,
|
||||
repo_url=repo_url,
|
||||
)
|
||||
|
||||
|
||||
def _service(**overrides) -> PluginCatalogService:
|
||||
"""构造完全依赖内存假对象的插件目录应用服务。"""
|
||||
defaults = {
|
||||
"market_loader": Mock(return_value={}),
|
||||
"async_market_loader": Mock(),
|
||||
"installed_plugins_provider": Mock(return_value=[]),
|
||||
"plugin_mapper": Mock(),
|
||||
"is_local_repo": lambda value: str(value).startswith("local://"),
|
||||
"version_compare": (
|
||||
lambda left, operator, right:
|
||||
operator == ">" and Version(left) > Version(right)
|
||||
),
|
||||
"warning": Mock(),
|
||||
"error": Mock(),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return PluginCatalogService(**defaults)
|
||||
|
||||
|
||||
def test_merge_prefers_higher_generation_over_same_base_entry():
|
||||
"""高代际索引出现同 ID 同版本时不再保留基础索引副本。"""
|
||||
service = _service()
|
||||
higher = _plugin("Demo", "2.0.0", "https://market-a")
|
||||
base = _plugin("Demo", "2.0.0", "https://market-b")
|
||||
|
||||
result = service.merge([higher], [base], ["https://market-a", "https://market-b"])
|
||||
|
||||
assert result == [higher]
|
||||
|
||||
|
||||
def test_merge_prefers_newer_version_and_remote_source():
|
||||
"""相同插件保留最高版本,同版本时市场来源覆盖本地副本。"""
|
||||
service = _service()
|
||||
old_remote = _plugin("Demo", "1.0.0", "https://market-a")
|
||||
new_local = _plugin("Demo", "2.0.0", "local://Demo")
|
||||
new_remote = _plugin("Demo", "2.0.0", "https://market-b")
|
||||
|
||||
result = service.merge(
|
||||
[old_remote, new_local, new_remote],
|
||||
[],
|
||||
["https://market-a", "https://market-b"],
|
||||
)
|
||||
|
||||
assert result == [new_remote]
|
||||
|
||||
|
||||
def test_load_maps_market_entries_with_installed_snapshot():
|
||||
"""单市场读取只获取一次已安装快照并按索引顺序映射 DTO。"""
|
||||
mapper = Mock(side_effect=lambda plugin_id, *_args: plugin_id)
|
||||
installed_provider = Mock(return_value=["Installed"])
|
||||
service = _service(
|
||||
market_loader=Mock(return_value={"First": {}, "Second": {}}),
|
||||
installed_plugins_provider=installed_provider,
|
||||
plugin_mapper=mapper,
|
||||
)
|
||||
|
||||
result = service.load("https://market-a", "v3", True)
|
||||
|
||||
assert result == ["First", "Second"]
|
||||
installed_provider.assert_called_once_with()
|
||||
assert mapper.call_args_list[0].args[3:] == (["Installed"], 2, "v3")
|
||||
assert mapper.call_args_list[1].args[3:] == (["Installed"], 1, "v3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_collect_isolates_failure_and_completes_progress():
|
||||
"""异步市场单任务失败时保留成功结果,并把进度推进到完成态。"""
|
||||
progress = Mock()
|
||||
error = Mock()
|
||||
service = _service(error=error)
|
||||
|
||||
async def loader(market: str, package_version: str | None, _force: bool):
|
||||
"""模拟一个失败代际和其余可正常完成的市场请求。"""
|
||||
await asyncio.sleep(0)
|
||||
if market == "https://market-a" and package_version == "v3":
|
||||
raise RuntimeError("unavailable")
|
||||
version = "2.0.0" if package_version else "1.0.0"
|
||||
return [_plugin(market, version, market)]
|
||||
|
||||
result = await service.async_collect(
|
||||
markets=["https://market-a", "https://market-b"],
|
||||
compatible_flags=["v3"],
|
||||
force=True,
|
||||
loader=loader,
|
||||
progress_callback=progress,
|
||||
)
|
||||
|
||||
assert {plugin.id for plugin in result} == {
|
||||
"https://market-a",
|
||||
"https://market-b",
|
||||
}
|
||||
error.assert_called_once()
|
||||
assert progress.call_args_list[0].kwargs["value"] == 0
|
||||
assert progress.call_args_list[-1].kwargs["value"] == 100
|
||||
@@ -0,0 +1,67 @@
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
|
||||
|
||||
def _command(calls: list[tuple], *, save_result: bool = True) -> PluginConfigCommand:
|
||||
"""构造记录端口调用顺序的插件配置用例。"""
|
||||
return PluginConfigCommand(
|
||||
save_config=lambda plugin_id, config, force: (
|
||||
calls.append(("save", plugin_id, config, force)) or save_result
|
||||
),
|
||||
initialize=lambda plugin_id, config: calls.append(
|
||||
("initialize", plugin_id, config)
|
||||
),
|
||||
stop=lambda plugin_id: calls.append(("stop", plugin_id)),
|
||||
delete_config=lambda plugin_id, force: (
|
||||
calls.append(("delete_config", plugin_id, force)) or True
|
||||
),
|
||||
delete_data=lambda plugin_id, force: (
|
||||
calls.append(("delete_data", plugin_id, force)) or True
|
||||
),
|
||||
reload_runtime=lambda plugin_id: calls.append(("reload", plugin_id)),
|
||||
publish_reset=lambda plugin_id: calls.append(("publish", plugin_id)),
|
||||
refresh_registrations=lambda plugin_id: calls.append(
|
||||
("registrations", plugin_id)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_update_stops_before_runtime_side_effects_when_save_fails() -> None:
|
||||
"""配置持久化失败时不得初始化插件或刷新宿主注册。"""
|
||||
calls: list[tuple] = []
|
||||
|
||||
result = _command(calls, save_result=False).update("DemoPlugin", {"enabled": True})
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "插件配置保存失败"
|
||||
assert calls == [("save", "DemoPlugin", {"enabled": True}, False)]
|
||||
|
||||
|
||||
def test_update_refreshes_runtime_only_after_config_is_saved() -> None:
|
||||
"""配置保存成功后按初始化、注册刷新顺序生效。"""
|
||||
calls: list[tuple] = []
|
||||
|
||||
result = _command(calls).update("DemoPlugin", {"enabled": True})
|
||||
|
||||
assert result.success is True
|
||||
assert calls == [
|
||||
("save", "DemoPlugin", {"enabled": True}, False),
|
||||
("initialize", "DemoPlugin", {"enabled": True}),
|
||||
("registrations", "DemoPlugin"),
|
||||
]
|
||||
|
||||
|
||||
def test_reset_preserves_compensation_cleanup_and_reload_order() -> None:
|
||||
"""重置必须先让插件补偿,再停止、清理并重建运行态和注册。"""
|
||||
calls: list[tuple] = []
|
||||
|
||||
result = _command(calls).reset("DemoPlugin")
|
||||
|
||||
assert result.success is True
|
||||
assert calls == [
|
||||
("publish", "DemoPlugin"),
|
||||
("stop", "DemoPlugin"),
|
||||
("delete_config", "DemoPlugin", True),
|
||||
("delete_data", "DemoPlugin", True),
|
||||
("reload", "DemoPlugin"),
|
||||
("registrations", "DemoPlugin"),
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from packaging.version import Version
|
||||
|
||||
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
|
||||
|
||||
|
||||
def _write_requirements(root: Path, plugin_id: str, content: str) -> None:
|
||||
"""写入一个测试插件的 requirements 文件。"""
|
||||
plugin_dir = root / plugin_id.lower()
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "requirements.txt").write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
def test_find_missing_merges_only_installed_plugin_constraints(tmp_path, monkeypatch):
|
||||
"""依赖扫描只覆盖安装清单,并合并同名包的多插件约束。"""
|
||||
plugin_root = tmp_path / "plugins"
|
||||
_write_requirements(plugin_root, "Alpha", "Demo-Pkg>=2\n")
|
||||
_write_requirements(plugin_root, "Beta", "demo.pkg<4\n")
|
||||
_write_requirements(plugin_root, "Ignored", "unused>=1\n")
|
||||
installer = PluginDependencyInstaller(
|
||||
Mock(),
|
||||
installed_plugins_provider=lambda: ["Alpha", "Beta"],
|
||||
plugin_dir=plugin_root,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
installer,
|
||||
"_installed_packages",
|
||||
lambda: {"demo_pkg": Version("1.0")},
|
||||
)
|
||||
|
||||
missing = installer.find_missing()
|
||||
|
||||
assert len(missing) == 1
|
||||
assert missing[0].startswith("demo_pkg")
|
||||
assert ">=2" in missing[0]
|
||||
assert "<4" in missing[0]
|
||||
assert all("unused" not in item for item in missing)
|
||||
|
||||
|
||||
def test_find_missing_skips_satisfied_constraints(tmp_path, monkeypatch):
|
||||
"""已安装版本满足合并约束时不得重复调用 pip。"""
|
||||
plugin_root = tmp_path / "plugins"
|
||||
_write_requirements(plugin_root, "Alpha", "demo>=1,<3\n")
|
||||
installer = PluginDependencyInstaller(
|
||||
Mock(),
|
||||
installed_plugins_provider=lambda: ["Alpha"],
|
||||
plugin_dir=plugin_root,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
installer,
|
||||
"_installed_packages",
|
||||
lambda: {"demo": Version("2.0")},
|
||||
)
|
||||
|
||||
assert installer.find_missing() == []
|
||||
|
||||
|
||||
def test_install_uses_adapter_owned_temporary_requirements(tmp_path, monkeypatch):
|
||||
"""批量依赖文件由依赖适配器创建并在 pip 返回后清理。"""
|
||||
helper = Mock()
|
||||
helper.pip_install_with_fallback.return_value = (True, "installed")
|
||||
monkeypatch.setattr(
|
||||
"app.adapters.system.plugin.dependency.settings",
|
||||
SimpleNamespace(ROOT_PATH=tmp_path, TEMP_PATH=tmp_path / "temp"),
|
||||
)
|
||||
installer = PluginDependencyInstaller(
|
||||
helper,
|
||||
installed_plugins_provider=lambda: [],
|
||||
plugin_dir=tmp_path / "plugins",
|
||||
)
|
||||
|
||||
result = installer.install(["demo>=2", "other"])
|
||||
|
||||
assert result == (True, "installed")
|
||||
requirements_file = helper.pip_install_with_fallback.call_args.args[0]
|
||||
assert requirements_file.name == "requirements.txt"
|
||||
assert not requirements_file.exists()
|
||||
@@ -7,6 +7,7 @@ from app.api.endpoints.plugin import plugin_history
|
||||
from app.api.endpoints.plugin import plugin_releases
|
||||
from app.api.endpoints.plugin import reset_plugin
|
||||
from app.api.endpoints.system import sync_plugin_market_from_wiki
|
||||
from app.application.plugin.config import PluginConfigCommand
|
||||
from app.runtime.config import settings
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.schemas.event import PluginDataResetEventData
|
||||
@@ -426,13 +427,29 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data():
|
||||
plugin_manager.delete_plugin_config.side_effect = delete_config
|
||||
plugin_manager.delete_plugin_data.side_effect = delete_data
|
||||
|
||||
with (
|
||||
patch("app.api.endpoints.plugin.PluginManager", return_value=plugin_manager),
|
||||
patch("app.api.endpoints.plugin.eventmanager") as eventmanager,
|
||||
patch("app.api.endpoints.plugin.reload_plugin") as reload_plugin_mock,
|
||||
):
|
||||
eventmanager.send_event.side_effect = lambda etype, data: calls.append(("event", etype, data))
|
||||
result = reset_plugin("SubscribeAssistantEnhanced", None)
|
||||
def publish_reset(plugin_id):
|
||||
"""记录重置前事件,验证应用用例保留补偿时序。"""
|
||||
calls.append((
|
||||
"event",
|
||||
ChainEventType.PluginDataReset,
|
||||
PluginDataResetEventData(
|
||||
plugin_id=plugin_id,
|
||||
reset_config=True,
|
||||
reset_data=True,
|
||||
),
|
||||
))
|
||||
|
||||
command = PluginConfigCommand(
|
||||
save_config=plugin_manager.save_plugin_config,
|
||||
initialize=plugin_manager.init_plugin,
|
||||
stop=plugin_manager.stop,
|
||||
delete_config=plugin_manager.delete_plugin_config,
|
||||
delete_data=plugin_manager.delete_plugin_data,
|
||||
reload_runtime=plugin_manager.reload_plugin,
|
||||
publish_reset=publish_reset,
|
||||
refresh_registrations=lambda _plugin_id: None,
|
||||
)
|
||||
result = reset_plugin("SubscribeAssistantEnhanced", None, command)
|
||||
|
||||
assert result.success is True
|
||||
assert len(calls) == 4
|
||||
@@ -448,7 +465,7 @@ def test_reset_plugin_sends_pre_reset_chain_event_before_deleting_data():
|
||||
("delete_config", "SubscribeAssistantEnhanced", True),
|
||||
("delete_data", "SubscribeAssistantEnhanced", True),
|
||||
]
|
||||
reload_plugin_mock.assert_called_once_with("SubscribeAssistantEnhanced")
|
||||
plugin_manager.reload_plugin.assert_called_once_with("SubscribeAssistantEnhanced")
|
||||
|
||||
|
||||
def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped():
|
||||
@@ -458,11 +475,12 @@ def test_delete_plugin_config_can_force_delete_after_plugin_is_stopped():
|
||||
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||
manager = PluginManager()
|
||||
|
||||
with patch("app.runtime.extensions.plugin_manager.SystemConfigOper") as system_config_oper:
|
||||
system_config_oper.return_value.delete.return_value = True
|
||||
storage = MagicMock()
|
||||
storage.delete.return_value = True
|
||||
with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage):
|
||||
assert manager.delete_plugin_config("DemoPlugin", force=True) is True
|
||||
|
||||
system_config_oper.return_value.delete.assert_called_once_with("plugin.DemoPlugin")
|
||||
storage.delete.assert_called_once_with("plugin.DemoPlugin")
|
||||
Singleton._instances.pop((PluginManager, (), frozenset()), None)
|
||||
|
||||
|
||||
@@ -474,8 +492,9 @@ def test_delete_plugin_data_can_force_delete_after_plugin_is_stopped():
|
||||
manager = PluginManager()
|
||||
calls = []
|
||||
|
||||
with patch("app.runtime.extensions.plugin_manager.PluginDataOper") as plugin_data_oper:
|
||||
plugin_data_oper.return_value.del_data.side_effect = lambda pid: calls.append(pid)
|
||||
storage = MagicMock()
|
||||
storage.delete_data.side_effect = lambda pid: calls.append(pid)
|
||||
with patch("app.runtime.extensions.plugin_manager.get_plugin_storage", return_value=storage):
|
||||
assert manager.delete_plugin_data("DemoPlugin", force=True) is True
|
||||
|
||||
assert calls == ["DemoPlugin"]
|
||||
|
||||
+41
-13
@@ -20,6 +20,33 @@ PLUGIN_ID = "DemoPlugin"
|
||||
REPO_URL = "https://github.com/demo/MoviePilot-Plugins"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configure_plugin_catalog_factory(monkeypatch):
|
||||
"""为直接构造 PluginManager 的测试注入真实目录用例和假持久化接缝。"""
|
||||
from app.adapters.external.plugin.client import PluginMarketClient
|
||||
from app.application.plugin.catalog import PluginCatalogService
|
||||
from app.foundation.version import compare_version
|
||||
from app.runtime.extensions import plugin_manager as manager_module
|
||||
|
||||
def build_catalog(manager):
|
||||
"""按生产组合方式连接目录服务,但保留测试可替换的依赖。"""
|
||||
client = PluginMarketClient()
|
||||
return PluginCatalogService(
|
||||
market_loader=client.get_plugins,
|
||||
async_market_loader=client.async_get_plugins,
|
||||
installed_plugins_provider=lambda: manager_module.get_plugin_storage().read(
|
||||
manager_module.SystemConfigKey.UserInstalledPlugins
|
||||
) or [],
|
||||
plugin_mapper=manager._process_plugin_info,
|
||||
is_local_repo=PluginMarketClient.is_local_repo_url,
|
||||
version_compare=compare_version,
|
||||
warning=manager_module.logger.warning,
|
||||
error=manager_module.logger.error,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(manager_module, "_plugin_catalog_factory", build_catalog)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""模拟 requests/httpx 响应对象,覆盖插件 release 安装分支读取的最小协议。"""
|
||||
|
||||
@@ -656,7 +683,10 @@ class TestPluginHelper:
|
||||
monkeypatch.setattr(plugin_manager, "_plugins", {})
|
||||
monkeypatch.setattr(plugin_manager, "_running_plugins", {})
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(VERSION_FLAG="v2"))
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.SystemConfigOper", lambda: SimpleNamespace(get=lambda _key: []))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.get_plugin_storage",
|
||||
lambda: SimpleNamespace(read=lambda _key: []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager._site_auth_level_provider",
|
||||
lambda: 1,
|
||||
@@ -717,7 +747,10 @@ class TestPluginHelper:
|
||||
SimpleNamespace(VERSION_FLAG="v3", PLUGIN_MARKET=REPO_URL),
|
||||
)
|
||||
monkeypatch.setattr("app.adapters.external.market.settings", SimpleNamespace(VERSION_FLAG="v3"))
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.SystemConfigOper", lambda: SimpleNamespace(get=lambda _key: []))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.get_plugin_storage",
|
||||
lambda: SimpleNamespace(read=lambda _key: []),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager._site_auth_level_provider",
|
||||
lambda: 1,
|
||||
@@ -876,10 +909,7 @@ class TestPluginHelper:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
|
||||
clear_calls = []
|
||||
fake_release_method = SimpleNamespace(cache_clear=lambda: clear_calls.append("clear"))
|
||||
fake_helper = SimpleNamespace(get_plugin_release_versions=fake_release_method)
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.PluginHelper", lambda: fake_helper)
|
||||
monkeypatch.setattr(PluginManager, "get_plugins_from_market", lambda *_args, **_kwargs: [])
|
||||
|
||||
PluginManager().get_online_plugins(force=True)
|
||||
@@ -898,14 +928,10 @@ class TestPluginHelper:
|
||||
async def fake_clear():
|
||||
clear_calls.append("clear")
|
||||
|
||||
fake_release_method = SimpleNamespace(cache_clear=fake_clear)
|
||||
fake_helper = SimpleNamespace(async_get_plugin_release_versions=fake_release_method)
|
||||
|
||||
async def fake_market(*_args, **_kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings.PLUGIN_MARKET", "https://github.com/demo/plugins")
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.PluginHelper", lambda: fake_helper)
|
||||
monkeypatch.setattr(PluginManager, "async_get_plugins_from_market", fake_market)
|
||||
|
||||
asyncio.run(PluginManager().async_get_online_plugins(force=True))
|
||||
@@ -916,7 +942,6 @@ class TestPluginHelper:
|
||||
"""单插件版本查询不构建全部本地插件信息。"""
|
||||
try:
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.db.oper.systemconfig import SystemConfigOper
|
||||
from app.schemas.types import SystemConfigKey
|
||||
except ModuleNotFoundError as exc:
|
||||
pytest.skip(f"missing dependency: {exc}")
|
||||
@@ -927,9 +952,12 @@ class TestPluginHelper:
|
||||
plugin_manager = PluginManager()
|
||||
monkeypatch.setattr(plugin_manager, "_plugins", {"DemoPlugin": DemoPlugin})
|
||||
monkeypatch.setattr(
|
||||
SystemConfigOper,
|
||||
"get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
"app.runtime.extensions.plugin_manager.get_plugin_storage",
|
||||
lambda: SimpleNamespace(
|
||||
read=lambda key: ["DemoPlugin"]
|
||||
if key == SystemConfigKey.UserInstalledPlugins
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
assert plugin_manager.get_local_plugin_version("DemoPlugin") == "1.2.0"
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.plugin.install import PluginInstallCommand
|
||||
|
||||
|
||||
def _command(
|
||||
*,
|
||||
installed=None,
|
||||
plugin_ids=None,
|
||||
compatibility=None,
|
||||
installer=None,
|
||||
reporter=None,
|
||||
writer=None,
|
||||
reloader=None,
|
||||
refresher=None,
|
||||
checkpointer=None,
|
||||
committer=None,
|
||||
rollback=None,
|
||||
):
|
||||
"""构造可观测每一步副作用的插件安装命令。"""
|
||||
return PluginInstallCommand(
|
||||
installed_plugins_reader=Mock(return_value=installed or []),
|
||||
installed_plugins_writer=writer or AsyncMock(),
|
||||
plugin_ids_provider=Mock(return_value=plugin_ids or []),
|
||||
compatibility_checker=compatibility or AsyncMock(return_value=None),
|
||||
package_installer=installer or AsyncMock(return_value=(True, "ok")),
|
||||
package_checkpointer=checkpointer or AsyncMock(return_value=object()),
|
||||
package_committer=committer or AsyncMock(),
|
||||
package_rollback=rollback or AsyncMock(),
|
||||
install_reporter=reporter or AsyncMock(),
|
||||
plugin_reloader=reloader or AsyncMock(),
|
||||
registration_refresher=refresher or AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_failure_stops_before_report_persistence_and_reload():
|
||||
"""包安装失败后恢复文件快照,且不得写配置、刷新或上报。"""
|
||||
reporter = AsyncMock()
|
||||
writer = AsyncMock()
|
||||
reloader = AsyncMock()
|
||||
rollback = AsyncMock()
|
||||
command = _command(
|
||||
installer=AsyncMock(return_value=(False, "download failed")),
|
||||
reporter=reporter,
|
||||
writer=writer,
|
||||
reloader=reloader,
|
||||
rollback=rollback,
|
||||
)
|
||||
|
||||
result = await command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.package_installed is False
|
||||
assert result.failure_stage == "package_install"
|
||||
assert result.rollback.file_restored is True
|
||||
assert result.rollback.dependency_supported is False
|
||||
rollback.assert_awaited_once()
|
||||
reporter.assert_not_awaited()
|
||||
writer.assert_not_awaited()
|
||||
reloader.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_records_completed_install_stages_in_order():
|
||||
"""成功安装在提交文件快照后再执行非关键远程上报。"""
|
||||
calls = []
|
||||
|
||||
async def install(*_args):
|
||||
calls.append("package")
|
||||
return True, "installed"
|
||||
|
||||
checkpoint = object()
|
||||
|
||||
async def create_checkpoint(_plugin_id):
|
||||
calls.append("checkpoint")
|
||||
return checkpoint
|
||||
|
||||
async def commit(target):
|
||||
assert target is checkpoint
|
||||
calls.append("commit")
|
||||
|
||||
async def report(*_args):
|
||||
calls.append("report")
|
||||
|
||||
async def write(_plugins):
|
||||
calls.append("persist")
|
||||
|
||||
async def reload(_plugin_id):
|
||||
calls.append("reload")
|
||||
|
||||
async def refresh(_plugin_id):
|
||||
calls.append("registrations")
|
||||
|
||||
result = await _command(
|
||||
installer=install,
|
||||
reporter=report,
|
||||
writer=write,
|
||||
reloader=reload,
|
||||
refresher=refresh,
|
||||
checkpointer=create_checkpoint,
|
||||
committer=commit,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.package_installed is True
|
||||
assert result.installed_list_persisted is True
|
||||
assert result.runtime_reloaded is True
|
||||
assert result.registrations_refreshed is True
|
||||
assert result.reported is True
|
||||
assert calls == [
|
||||
"checkpoint",
|
||||
"package",
|
||||
"persist",
|
||||
"reload",
|
||||
"registrations",
|
||||
"commit",
|
||||
"report",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_plugin_checks_compatibility_without_reinstalling_package():
|
||||
"""已存在插件只校验兼容性、上报和重载,不重复安装包。"""
|
||||
installer = AsyncMock()
|
||||
checkpointer = AsyncMock()
|
||||
command = _command(
|
||||
installed=["DemoPlugin"],
|
||||
plugin_ids=["DemoPlugin"],
|
||||
installer=installer,
|
||||
checkpointer=checkpointer,
|
||||
)
|
||||
|
||||
result = await command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.refreshed_only is True
|
||||
assert result.package_installed is False
|
||||
assert result.installed_list_persisted is False
|
||||
installer.assert_not_awaited()
|
||||
checkpointer.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persistence_failure_restores_package_without_touching_runtime():
|
||||
"""已安装列表保存失败时恢复文件,且运行态尚未开始切换。"""
|
||||
checkpoint = object()
|
||||
rollback = AsyncMock()
|
||||
reloader = AsyncMock()
|
||||
command = _command(
|
||||
checkpointer=AsyncMock(return_value=checkpoint),
|
||||
writer=AsyncMock(side_effect=RuntimeError("db unavailable")),
|
||||
rollback=rollback,
|
||||
reloader=reloader,
|
||||
)
|
||||
|
||||
result = await command.execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "installed_list_persistence"
|
||||
assert result.rollback.file_restored is True
|
||||
assert result.rollback.installed_list_attempted is False
|
||||
assert result.rollback.runtime_attempted is False
|
||||
rollback.assert_awaited_once_with(checkpoint)
|
||||
reloader.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_failure_restores_list_files_and_previous_runtime():
|
||||
"""重载失败时依次恢复已安装列表、包文件和旧运行态。"""
|
||||
calls = []
|
||||
checkpoint = object()
|
||||
reload_count = 0
|
||||
|
||||
async def write(plugin_ids):
|
||||
calls.append(("persist", list(plugin_ids)))
|
||||
|
||||
async def rollback(target):
|
||||
assert target is checkpoint
|
||||
calls.append(("rollback", target))
|
||||
|
||||
async def reload(_plugin_id):
|
||||
nonlocal reload_count
|
||||
reload_count += 1
|
||||
calls.append(("reload", reload_count))
|
||||
if reload_count == 1:
|
||||
raise RuntimeError("route registration failed")
|
||||
|
||||
async def refresh(_plugin_id):
|
||||
calls.append(("registrations", reload_count))
|
||||
|
||||
result = await _command(
|
||||
installed=[],
|
||||
checkpointer=AsyncMock(return_value=checkpoint),
|
||||
writer=write,
|
||||
rollback=rollback,
|
||||
reloader=reload,
|
||||
refresher=refresh,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "runtime_reload"
|
||||
assert result.rollback.file_restored is True
|
||||
assert result.rollback.installed_list_restored is True
|
||||
assert result.rollback.runtime_restored is True
|
||||
assert result.rollback.registrations_restored is True
|
||||
assert calls == [
|
||||
("persist", ["DemoPlugin"]),
|
||||
("reload", 1),
|
||||
("persist", []),
|
||||
("rollback", checkpoint),
|
||||
("reload", 2),
|
||||
("registrations", 2),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registration_failure_restores_instance_files_and_routes() -> None:
|
||||
"""动态路由刷新失败时恢复列表、文件、旧实例并再次刷新旧注册。"""
|
||||
calls = []
|
||||
checkpoint = object()
|
||||
refresh_count = 0
|
||||
|
||||
async def write(plugin_ids):
|
||||
calls.append(("persist", list(plugin_ids)))
|
||||
|
||||
async def rollback(target):
|
||||
assert target is checkpoint
|
||||
calls.append(("rollback", target))
|
||||
|
||||
async def reload(_plugin_id):
|
||||
calls.append("reload")
|
||||
|
||||
async def refresh(_plugin_id):
|
||||
nonlocal refresh_count
|
||||
refresh_count += 1
|
||||
calls.append(("registrations", refresh_count))
|
||||
if refresh_count == 1:
|
||||
raise RuntimeError("route registration failed")
|
||||
|
||||
result = await _command(
|
||||
checkpointer=AsyncMock(return_value=checkpoint),
|
||||
writer=write,
|
||||
rollback=rollback,
|
||||
reloader=reload,
|
||||
refresher=refresh,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is False
|
||||
assert result.failure_stage == "registration_refresh"
|
||||
assert result.rollback.file_restored is True
|
||||
assert result.rollback.installed_list_restored is True
|
||||
assert result.rollback.runtime_restored is True
|
||||
assert result.rollback.registrations_restored is True
|
||||
assert calls == [
|
||||
("persist", ["DemoPlugin"]),
|
||||
"reload",
|
||||
("registrations", 1),
|
||||
("persist", []),
|
||||
("rollback", checkpoint),
|
||||
"reload",
|
||||
("registrations", 2),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_failure_does_not_rollback_completed_local_install():
|
||||
"""统计上报失败属于非关键副作用,不得撤销已成功的本地安装。"""
|
||||
rollback = AsyncMock()
|
||||
result = await _command(
|
||||
reporter=AsyncMock(side_effect=RuntimeError("server unavailable")),
|
||||
rollback=rollback,
|
||||
).execute(
|
||||
plugin_id="DemoPlugin",
|
||||
repo_url="https://github.com/demo/plugins",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.runtime_reloaded is True
|
||||
assert result.reported is False
|
||||
assert result.report_error == "server unavailable"
|
||||
assert "不影响本地安装" in result.message
|
||||
rollback.assert_not_awaited()
|
||||
@@ -64,10 +64,12 @@ def _configure_local_watcher(
|
||||
PLUGIN_AUTO_RELOAD=True,
|
||||
PLUGIN_LOCAL_REPO_PATHS=str(repo_path),
|
||||
ROOT_PATH=tmp_path,
|
||||
TEMP_PATH=tmp_path / "temp",
|
||||
VERSION_FLAG="v2",
|
||||
)
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
|
||||
monkeypatch.setattr("app.adapters.external.market.settings", settings_stub)
|
||||
monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub)
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.watch", lambda *_args, **_kwargs: iter([changes]))
|
||||
|
||||
|
||||
@@ -82,6 +84,19 @@ def _set_running_render_mode(
|
||||
)
|
||||
|
||||
|
||||
def _set_installed_plugins(monkeypatch, plugin_ids: list[str]) -> None:
|
||||
"""注入本地同步测试所需的已安装插件读取端口。"""
|
||||
storage = SimpleNamespace(
|
||||
read=lambda key: plugin_ids
|
||||
if key == SystemConfigKey.UserInstalledPlugins
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.get_plugin_storage",
|
||||
lambda: storage,
|
||||
)
|
||||
|
||||
|
||||
class _FakeSchedulerBackend:
|
||||
"""提供插件服务增删所需的最小 APScheduler 契约。"""
|
||||
|
||||
@@ -119,13 +134,16 @@ def test_dev_local_plugin_candidate_keeps_hot_sync_allowed_when_system_version_l
|
||||
repo_path, source_file = _build_local_plugin_repo(tmp_path)
|
||||
runtime_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", SimpleNamespace(DEV=True, ROOT_PATH=tmp_path))
|
||||
settings_stub = SimpleNamespace(
|
||||
DEV=True,
|
||||
ROOT_PATH=tmp_path,
|
||||
TEMP_PATH=tmp_path / "temp",
|
||||
)
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
|
||||
monkeypatch.setattr("app.adapters.system.plugin.package.settings", settings_stub)
|
||||
monkeypatch.setattr("app.adapters.external.market.settings.PLUGIN_LOCAL_REPO_PATHS", str(repo_path))
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.SystemConfigOper.get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
|
||||
|
||||
candidate = plugin_manager._get_local_plugin_candidate_from_path(source_file)
|
||||
|
||||
@@ -174,10 +192,7 @@ def test_local_plugin_sync_without_candidate_respects_system_version_gate(
|
||||
monkeypatch.setattr("app.runtime.extensions.plugin_manager.settings", settings_stub)
|
||||
monkeypatch.setattr("app.adapters.external.market.settings", settings_stub)
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.10"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.SystemConfigOper.get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
|
||||
|
||||
assert not plugin_manager._sync_local_plugin_if_installed("DemoPlugin")
|
||||
assert not runtime_dir.exists()
|
||||
@@ -204,10 +219,7 @@ def test_local_federated_asset_batch_syncs_once_without_python_reload(
|
||||
)
|
||||
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.SystemConfigOper.get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
|
||||
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
|
||||
reload_spy = Mock()
|
||||
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
|
||||
@@ -461,10 +473,7 @@ def test_local_python_change_still_syncs_and_reloads_plugin(
|
||||
{(Change.modified, str(source_file))},
|
||||
)
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.SystemConfigOper.get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
|
||||
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
|
||||
reload_spy = Mock()
|
||||
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
|
||||
@@ -493,10 +502,7 @@ def test_local_python_change_rejects_root_federated_path_and_still_reloads(
|
||||
)
|
||||
_set_running_render_mode(plugin_manager, "vue", dist_path)
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.SystemConfigOper.get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
|
||||
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
|
||||
reload_spy = Mock()
|
||||
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
|
||||
@@ -529,10 +535,7 @@ def test_local_python_and_federated_changes_share_one_batch_sync(
|
||||
)
|
||||
_set_running_render_mode(plugin_manager, "vue", "dist/assets")
|
||||
monkeypatch.setattr(PluginHelper, "get_current_system_version", lambda: Version("2.13.11"))
|
||||
monkeypatch.setattr(
|
||||
"app.runtime.extensions.plugin_manager.SystemConfigOper.get",
|
||||
lambda _self, key: ["DemoPlugin"] if key == SystemConfigKey.UserInstalledPlugins else None,
|
||||
)
|
||||
_set_installed_plugins(monkeypatch, ["DemoPlugin"])
|
||||
sync_spy = Mock(wraps=plugin_manager._sync_local_plugin_if_installed)
|
||||
reload_spy = Mock()
|
||||
monkeypatch.setattr(plugin_manager, "_sync_local_plugin_if_installed", sync_spy)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.adapters.system.plugin.package import PluginPackageManager
|
||||
|
||||
|
||||
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"),
|
||||
)
|
||||
return PluginPackageManager(helper=Mock())
|
||||
|
||||
|
||||
def test_checkpoint_rollback_restores_existing_package(monkeypatch, tmp_path):
|
||||
"""已存在插件在后续阶段失败时应完整恢复原文件。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
source_file = plugin_dir / "__init__.py"
|
||||
source_file.write_text("old", encoding="utf-8")
|
||||
|
||||
checkpoint = manager.checkpoint("DemoPlugin")
|
||||
source_file.write_text("new", encoding="utf-8")
|
||||
(plugin_dir / "partial.py").write_text("partial", encoding="utf-8")
|
||||
manager.rollback(checkpoint)
|
||||
|
||||
assert source_file.read_text(encoding="utf-8") == "old"
|
||||
assert not (plugin_dir / "partial.py").exists()
|
||||
assert not checkpoint.transaction_dir.exists()
|
||||
|
||||
|
||||
def test_checkpoint_rollback_removes_new_package(monkeypatch, tmp_path):
|
||||
"""首次安装失败时应删除安装过程创建的不完整目录。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
checkpoint = manager.checkpoint("DemoPlugin")
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text("partial", encoding="utf-8")
|
||||
|
||||
manager.rollback(checkpoint)
|
||||
|
||||
assert not plugin_dir.exists()
|
||||
assert not checkpoint.transaction_dir.exists()
|
||||
|
||||
|
||||
def test_local_sync_failure_restores_previous_runtime_copy(monkeypatch, tmp_path):
|
||||
"""本地来源不可复制时不得丢失已经运行的插件副本。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
source_file = plugin_dir / "__init__.py"
|
||||
source_file.write_text("stable", encoding="utf-8")
|
||||
missing_source = tmp_path / "missing" / "demoplugin"
|
||||
|
||||
assert manager.sync_local("DemoPlugin", missing_source) is False
|
||||
|
||||
assert source_file.read_text(encoding="utf-8") == "stable"
|
||||
|
||||
|
||||
def test_clone_rewrites_python_and_federation_assets(monkeypatch, tmp_path):
|
||||
"""插件分身文件处理应由包适配器完成并隔离配置命名空间。"""
|
||||
manager = _manager(monkeypatch, tmp_path)
|
||||
plugin_dir = tmp_path / "app" / "plugins" / "demoplugin"
|
||||
dist_dir = plugin_dir / "dist"
|
||||
dist_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
"class DemoPlugin:\n"
|
||||
" plugin_name = 'Demo'\n"
|
||||
" plugin_desc = 'Description'\n"
|
||||
" plugin_config_prefix = 'demo_'\n"
|
||||
" plugin_version = '1.0.0'\n"
|
||||
" plugin_icon = 'old.png'\n"
|
||||
" def init_plugin(self, config=None):\n"
|
||||
" pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(dist_dir / "demoplugin.js").write_text(
|
||||
"const name = 'DemoPlugin'; const css = 'css__DemoPlugin__root';",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
success, message = manager.clone(
|
||||
plugin_id="DemoPlugin",
|
||||
clone_id="DemoPluginBlue",
|
||||
original_class_name="DemoPlugin",
|
||||
suffix="blue",
|
||||
name="Demo Blue",
|
||||
description="Blue clone",
|
||||
version="2.0.0",
|
||||
icon="blue.png",
|
||||
)
|
||||
|
||||
clone_dir = tmp_path / "app" / "plugins" / "demopluginblue"
|
||||
clone_source = (clone_dir / "__init__.py").read_text(encoding="utf-8")
|
||||
assert success is True
|
||||
assert message == "文件修改成功"
|
||||
assert "class DemoPluginblue" in clone_source
|
||||
assert 'plugin_name = "Demo Blue"' in clone_source
|
||||
assert 'plugin_config_prefix = "demopluginblue_"' in clone_source
|
||||
assert "is_clone = True" in clone_source
|
||||
assert (clone_dir / "dist" / "demopluginblue.js").is_file()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""插件能力投影的隔离契约测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.runtime.extensions.plugin.projection import PluginProjection
|
||||
|
||||
|
||||
class _Plugin(SimpleNamespace):
|
||||
"""提供可配置插件 hook 的最小运行态插件替身。"""
|
||||
|
||||
def __init__(self, enabled=True, **hooks):
|
||||
"""保存启用状态、插件名称和 hook 实现。"""
|
||||
super().__init__(plugin_name=hooks.pop("plugin_name", "测试插件"), **hooks)
|
||||
self._enabled = enabled
|
||||
|
||||
def get_state(self):
|
||||
"""返回预设启用状态。"""
|
||||
return self._enabled
|
||||
|
||||
def get_name(self):
|
||||
"""返回插件展示名称。"""
|
||||
return self.plugin_name
|
||||
|
||||
|
||||
def test_projection_preserves_commands_and_api_adaptation():
|
||||
"""命令补 pid,API 补宿主路径与默认认证方式。"""
|
||||
command = {"cmd": "/demo"}
|
||||
api = {"path": "/items", "endpoint": object()}
|
||||
plugin = _Plugin(
|
||||
get_command=lambda: [command],
|
||||
get_api=lambda: [api],
|
||||
)
|
||||
projection = PluginProjection({"Demo": plugin})
|
||||
|
||||
assert projection.commands() == [{"cmd": "/demo", "pid": "Demo"}]
|
||||
assert projection.apis() == [{
|
||||
"path": "/Demo/items",
|
||||
"endpoint": api["endpoint"],
|
||||
"auth": "apikey",
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_filters_disabled_stateful_hooks_but_keeps_api_contract():
|
||||
"""禁用插件不暴露命令/服务/模块/动作,API 保持历史上的独立注册语义。"""
|
||||
plugin = _Plugin(
|
||||
enabled=False,
|
||||
get_command=lambda: [{"cmd": "/demo"}],
|
||||
get_api=lambda: [{"path": "/items"}],
|
||||
get_service=lambda: [{"id": "job"}],
|
||||
get_module=lambda: {"recognize": object()},
|
||||
get_actions=lambda: [{"id": "action"}],
|
||||
)
|
||||
projection = PluginProjection({"Demo": plugin})
|
||||
|
||||
assert projection.commands() == []
|
||||
assert projection.services() == []
|
||||
assert projection.modules() == {}
|
||||
assert projection.actions() == []
|
||||
assert projection.apis() == [{"path": "/Demo/items", "auth": "apikey"}]
|
||||
|
||||
|
||||
def test_projection_preserves_services_modules_actions_and_pid_filter():
|
||||
"""指定 pid 时只投影目标插件,并保持各 hook 的原始结构。"""
|
||||
demo = _Plugin(
|
||||
get_service=lambda: [{"id": "job"}],
|
||||
get_module=lambda: {"recognize": "handler"},
|
||||
get_actions=lambda: [{"id": "action"}],
|
||||
)
|
||||
other = _Plugin(get_service=lambda: [{"id": "other"}])
|
||||
projection = PluginProjection({"Demo": demo, "Other": other})
|
||||
|
||||
assert projection.services("Demo") == [{"id": "job"}]
|
||||
assert projection.modules("Demo") == {
|
||||
("Demo", "测试插件"): {"recognize": "handler"}
|
||||
}
|
||||
assert projection.actions("Demo") == [{
|
||||
"plugin_id": "Demo",
|
||||
"plugin_name": "测试插件",
|
||||
"actions": [{"id": "action"}],
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_isolates_one_plugin_hook_failure():
|
||||
"""单个插件 hook 失败只记日志,不阻断其他插件投影。"""
|
||||
errors = []
|
||||
log = SimpleNamespace(error=lambda message: errors.append(message))
|
||||
|
||||
def fail():
|
||||
"""模拟插件 hook 抛出异常。"""
|
||||
raise RuntimeError("broken")
|
||||
|
||||
projection = PluginProjection(
|
||||
{
|
||||
"Broken": _Plugin(get_service=fail),
|
||||
"Healthy": _Plugin(get_service=lambda: [{"id": "healthy"}]),
|
||||
},
|
||||
log=log,
|
||||
)
|
||||
|
||||
assert projection.services() == [{"id": "healthy"}]
|
||||
assert errors and "Broken" in errors[0]
|
||||
|
||||
|
||||
def test_projection_builds_federation_and_auth_provider_entries():
|
||||
"""联邦远程入口和插件认证入口保持既有字段与默认值。"""
|
||||
plugin = _Plugin(
|
||||
get_render_mode=lambda: ("vue", "dist/assets"),
|
||||
get_auth_providers=lambda: [{"id": "demo-login"}],
|
||||
)
|
||||
projection = PluginProjection(
|
||||
{"Demo": plugin},
|
||||
remote_entry_factory=lambda plugin_id, path: f"/{plugin_id}/{path}",
|
||||
)
|
||||
|
||||
assert projection.remotes() == [{
|
||||
"id": "Demo",
|
||||
"url": "/Demo/dist/assets",
|
||||
"name": "测试插件",
|
||||
}]
|
||||
assert projection.auth_providers() == [{
|
||||
"id": "demo-login",
|
||||
"type": "plugin",
|
||||
"plugin_id": "Demo",
|
||||
"name": "测试插件",
|
||||
"enabled": True,
|
||||
"component": "AuthPage",
|
||||
"remote": {
|
||||
"id": "Demo",
|
||||
"url": "/Demo/dist/assets",
|
||||
"name": "测试插件",
|
||||
},
|
||||
}]
|
||||
|
||||
|
||||
def test_projection_normalizes_sidebar_and_dashboard_metadata():
|
||||
"""侧栏和仪表板元数据在投影层完成校验、排序与兼容默认值。"""
|
||||
plugin = _Plugin(
|
||||
get_render_mode=lambda: ("vue", "dist"),
|
||||
get_sidebar_nav=lambda: [{
|
||||
"key": "settings",
|
||||
"section": "invalid",
|
||||
"permission": "invalid",
|
||||
"order": "3",
|
||||
}],
|
||||
get_dashboard=lambda: ({}, {}, []),
|
||||
get_dashboard_meta=lambda: [{"name": "状态", "key": "status"}],
|
||||
)
|
||||
projection = PluginProjection({"Demo": plugin})
|
||||
|
||||
assert projection.sidebar() == [{
|
||||
"plugin_id": "Demo",
|
||||
"nav_key": "settings",
|
||||
"title": "测试插件",
|
||||
"icon": "mdi-puzzle",
|
||||
"section": "system",
|
||||
"permission": None,
|
||||
"order": 3,
|
||||
}]
|
||||
assert projection.dashboard_metadata() == [{
|
||||
"id": "Demo",
|
||||
"name": "状态",
|
||||
"key": "status",
|
||||
}]
|
||||
@@ -0,0 +1,38 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.runtime.extensions.plugin.registry import PluginRegistry
|
||||
|
||||
|
||||
def test_registry_owns_classes_instances_and_stable_snapshots():
|
||||
"""注册表集中持有类与实例,快照不受后续热重载修改影响。"""
|
||||
registry = PluginRegistry()
|
||||
plugin_class = type("Demo", (), {})
|
||||
plugin_instance = SimpleNamespace(plugin_name="演示")
|
||||
registry.classes["Demo"] = plugin_class
|
||||
registry.running["Demo"] = plugin_instance
|
||||
|
||||
snapshot = registry.running_snapshot()
|
||||
registry.running["Other"] = SimpleNamespace(plugin_name="其它")
|
||||
|
||||
assert registry.has_class("Demo")
|
||||
assert registry.plugin_class("Demo") is plugin_class
|
||||
assert registry.instance("Demo") is plugin_instance
|
||||
assert registry.plugin_ids() == ["Demo"]
|
||||
assert registry.running_ids() == ["Demo", "Other"]
|
||||
assert list(snapshot) == ["Demo"]
|
||||
|
||||
|
||||
def test_registry_clear_preserves_compatibility_mapping_identity():
|
||||
"""整体停止插件时原地清空,旧调用方持有的字典引用继续有效。"""
|
||||
registry = PluginRegistry()
|
||||
classes = registry.classes
|
||||
running = registry.running
|
||||
classes["Demo"] = object()
|
||||
running["Demo"] = object()
|
||||
|
||||
registry.clear()
|
||||
|
||||
assert registry.classes is classes
|
||||
assert registry.running is running
|
||||
assert classes == {}
|
||||
assert running == {}
|
||||
@@ -0,0 +1,41 @@
|
||||
from app.foundation.singleton import Singleton
|
||||
from app.runtime.events import EventManager, eventmanager
|
||||
from app.runtime.extensions.module_manager import ModuleManager
|
||||
from app.runtime.extensions.plugin_manager import PluginManager
|
||||
from app.sdk.plugins import ModuleManager as SdkModuleManager
|
||||
from app.sdk.plugins import PluginManager as SdkPluginManager
|
||||
|
||||
|
||||
def _singleton_key(manager_type: type) -> tuple:
|
||||
"""返回无参数 Singleton 管理器使用的缓存键。"""
|
||||
return manager_type, (), frozenset()
|
||||
|
||||
|
||||
def test_event_manager_global_and_constructor_share_identity():
|
||||
"""事件全局对象与公开构造入口必须指向同一单例。"""
|
||||
assert EventManager() is eventmanager
|
||||
assert EventManager() is EventManager()
|
||||
|
||||
|
||||
def test_module_manager_sdk_and_runtime_share_identity(monkeypatch):
|
||||
"""模块管理器 SDK 与运行时入口必须解析到同一单例对象。"""
|
||||
instance = object.__new__(ModuleManager)
|
||||
instances = dict(Singleton._instances)
|
||||
instances[_singleton_key(ModuleManager)] = instance
|
||||
monkeypatch.setattr(Singleton, "_instances", instances)
|
||||
|
||||
assert SdkModuleManager is ModuleManager
|
||||
assert SdkModuleManager() is instance
|
||||
assert ModuleManager() is instance
|
||||
|
||||
|
||||
def test_plugin_manager_sdk_and_runtime_share_identity(monkeypatch):
|
||||
"""插件管理器 SDK 与运行时入口必须解析到同一单例对象。"""
|
||||
instance = object.__new__(PluginManager)
|
||||
instances = dict(Singleton._instances)
|
||||
instances[_singleton_key(PluginManager)] = instance
|
||||
monkeypatch.setattr(Singleton, "_instances", instances)
|
||||
|
||||
assert SdkPluginManager is PluginManager
|
||||
assert SdkPluginManager() is instance
|
||||
assert PluginManager() is instance
|
||||
@@ -11,6 +11,7 @@ from app.chain.subscribe import SubscribeChain
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.schemas.media import normalize_media_source
|
||||
from app.schemas.workflow import MediaInfo as SchemaMediaInfo
|
||||
|
||||
|
||||
def test_media_source_normalization_accepts_plugin_source() -> None:
|
||||
@@ -203,7 +204,7 @@ def test_media_detail_does_not_fallback_for_explicit_identity(monkeypatch) -> No
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, media_endpoint.schemas.MediaInfo)
|
||||
assert isinstance(result, SchemaMediaInfo)
|
||||
media_chain.async_recognize_by_meta.assert_not_awaited()
|
||||
|
||||
|
||||
@@ -221,7 +222,7 @@ def test_media_detail_rejects_zero_identity_before_chain(monkeypatch) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, media_endpoint.schemas.MediaInfo)
|
||||
assert isinstance(result, SchemaMediaInfo)
|
||||
media_chain.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""搜索状态应用服务测试。"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.application.search.state import SearchStateService
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def test_search_state_normalizes_identity_and_preserves_cache_contract():
|
||||
"""搜索参数保存后应保留媒体身份和前端原有字段。"""
|
||||
saved = []
|
||||
service = SearchStateService(
|
||||
save_cache=lambda value, key: saved.append((key, value)),
|
||||
load_cache=lambda _key: saved[-1][1],
|
||||
async_save_cache=lambda value, key: None,
|
||||
async_load_cache=lambda _key: None,
|
||||
params_key="params",
|
||||
result_key="results",
|
||||
subtitle_result_key="subtitles",
|
||||
)
|
||||
|
||||
service.save_params(
|
||||
keyword="tmdb:123",
|
||||
media_source=None,
|
||||
media_id=None,
|
||||
mtype=MediaType.MOVIE,
|
||||
sites=[1, 2],
|
||||
)
|
||||
|
||||
assert saved == [("params", {
|
||||
"keyword": "",
|
||||
"media_source": str(MediaSource.TMDB),
|
||||
"media_id": "123",
|
||||
"type": MediaType.MOVIE.value,
|
||||
"area": "title",
|
||||
"title": "",
|
||||
"year": "",
|
||||
"season": "",
|
||||
"episode": "",
|
||||
"sites": "1,2",
|
||||
"result_type": "torrent",
|
||||
})]
|
||||
assert service.load_params() == saved[0][1]
|
||||
|
||||
|
||||
def test_search_state_async_paths_use_injected_ports():
|
||||
"""异步保存和读取必须只使用注入的缓存端口。"""
|
||||
saved = {}
|
||||
|
||||
async def save(value, key):
|
||||
"""记录异步缓存写入。"""
|
||||
saved[key] = value
|
||||
|
||||
async def load(key):
|
||||
"""返回异步缓存内容。"""
|
||||
return saved.get(key)
|
||||
|
||||
service = SearchStateService(
|
||||
save_cache=lambda *_args: None,
|
||||
load_cache=lambda _key: None,
|
||||
async_save_cache=save,
|
||||
async_load_cache=load,
|
||||
params_key="params",
|
||||
result_key="results",
|
||||
subtitle_result_key="subtitles",
|
||||
)
|
||||
|
||||
asyncio.run(service.async_save_params(keyword="hello"))
|
||||
assert asyncio.run(service.async_load_params())["keyword"] == "hello"
|
||||
@@ -1,9 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.adapters.external.server import MoviePilotServerHelper
|
||||
from app.adapters.external.server import (
|
||||
MoviePilotServerHelper,
|
||||
configure_server_application_services,
|
||||
)
|
||||
from app.application.server.report import ServerReportService
|
||||
from app.application.server.share import ServerSharingService
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
@@ -17,6 +22,32 @@ class MoviePilotServerHelperTests(unittest.TestCase):
|
||||
清理安装用户 ID 缓存,避免不同用例之间互相影响。
|
||||
"""
|
||||
MoviePilotServerHelper._user_uid = None
|
||||
configure_server_application_services(
|
||||
report_service=ServerReportService(
|
||||
config_reader=Mock(return_value=None),
|
||||
config_writer=Mock(),
|
||||
installed_plugins_provider=Mock(return_value=[]),
|
||||
subscribes_provider=Mock(return_value=[]),
|
||||
plugin_report_sender=Mock(),
|
||||
async_plugin_report_sender=AsyncMock(),
|
||||
subscribe_report_sender=Mock(),
|
||||
repo_url_sanitizer=MoviePilotServerHelper.sanitize_plugin_repo_url,
|
||||
),
|
||||
sharing_service=ServerSharingService(
|
||||
subscribe_provider=Mock(return_value=None),
|
||||
async_subscribe_provider=AsyncMock(return_value=None),
|
||||
workflow_provider=Mock(return_value=None),
|
||||
async_workflow_provider=AsyncMock(return_value=None),
|
||||
user_uuid_provider=Mock(return_value="user-1"),
|
||||
subscribe_sender=Mock(),
|
||||
async_subscribe_sender=AsyncMock(),
|
||||
workflow_sender=Mock(),
|
||||
async_workflow_sender=AsyncMock(),
|
||||
response_handler=Mock(return_value=(True, "")),
|
||||
subscribe_cache_clearer=Mock(),
|
||||
workflow_cache_clearer=Mock(),
|
||||
),
|
||||
)
|
||||
|
||||
def test_server_request_adds_user_uid_header(self):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.application.server.report import ServerReportService
|
||||
|
||||
|
||||
def _service(**overrides) -> ServerReportService:
|
||||
"""构造不依赖数据库和网络的中心服务上报用例。"""
|
||||
defaults = {
|
||||
"config_reader": Mock(return_value=None),
|
||||
"config_writer": Mock(),
|
||||
"installed_plugins_provider": Mock(return_value=[]),
|
||||
"subscribes_provider": Mock(return_value=[]),
|
||||
"plugin_report_sender": Mock(
|
||||
return_value=SimpleNamespace(status_code=200)
|
||||
),
|
||||
"async_plugin_report_sender": Mock(),
|
||||
"subscribe_report_sender": Mock(
|
||||
return_value=SimpleNamespace(status_code=200)
|
||||
),
|
||||
"repo_url_sanitizer": lambda value: value,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ServerReportService(**defaults)
|
||||
|
||||
|
||||
def test_subscribe_report_uses_fake_local_reader_and_transport():
|
||||
"""订阅存量上报只依赖注入的数据读取和发送端口。"""
|
||||
sender = Mock(return_value=SimpleNamespace(status_code=200))
|
||||
subscribe = SimpleNamespace(to_dict=lambda: {
|
||||
"name": "Demo",
|
||||
"type": "电影",
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "123",
|
||||
"username": "private",
|
||||
})
|
||||
service = _service(
|
||||
subscribes_provider=Mock(return_value=[subscribe]),
|
||||
subscribe_report_sender=sender,
|
||||
)
|
||||
|
||||
assert service.report_subscribes(enabled=True) is True
|
||||
sender.assert_called_once_with([{
|
||||
"name": "Demo",
|
||||
"type": "电影",
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "123",
|
||||
}])
|
||||
|
||||
|
||||
def test_initial_report_marker_is_written_only_after_success():
|
||||
"""首次上报失败时不得提前写完成标记。"""
|
||||
writer = Mock()
|
||||
service = _service(config_writer=writer)
|
||||
|
||||
service.init_report(
|
||||
enabled=True,
|
||||
state_key="report",
|
||||
reporter=Mock(return_value=False),
|
||||
)
|
||||
|
||||
writer.assert_not_called()
|
||||
|
||||
|
||||
def test_plugin_report_sanitizes_explicit_sources_before_transport():
|
||||
"""插件统计载荷在进入传输适配器前完成来源脱敏。"""
|
||||
sender = Mock(return_value=SimpleNamespace(status_code=200))
|
||||
service = _service(
|
||||
plugin_report_sender=sender,
|
||||
repo_url_sanitizer=lambda value: "local://Demo" if value else value,
|
||||
)
|
||||
|
||||
assert service.report_plugins(
|
||||
enabled=True,
|
||||
items=[("Demo", "local://Demo?path=/private/repo")],
|
||||
) is True
|
||||
sender.assert_called_once_with([{
|
||||
"plugin_id": "Demo",
|
||||
"repo_url": "local://Demo",
|
||||
}])
|
||||
@@ -0,0 +1,112 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.application.server.share import ServerSharingService
|
||||
|
||||
|
||||
def _service(**overrides) -> ServerSharingService:
|
||||
"""构造不依赖数据库和网络的中心服务分享用例。"""
|
||||
def handle_response(response, clear):
|
||||
"""模拟旧 Helper 的成功响应和缓存失效顺序。"""
|
||||
clear()
|
||||
return response.status_code == 200, ""
|
||||
|
||||
defaults = {
|
||||
"subscribe_provider": Mock(return_value=None),
|
||||
"async_subscribe_provider": AsyncMock(return_value=None),
|
||||
"workflow_provider": Mock(return_value=None),
|
||||
"async_workflow_provider": AsyncMock(return_value=None),
|
||||
"user_uuid_provider": Mock(return_value="user-1"),
|
||||
"subscribe_sender": Mock(),
|
||||
"async_subscribe_sender": AsyncMock(),
|
||||
"workflow_sender": Mock(),
|
||||
"async_workflow_sender": AsyncMock(),
|
||||
"response_handler": handle_response,
|
||||
"subscribe_cache_clearer": Mock(),
|
||||
"workflow_cache_clearer": Mock(),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ServerSharingService(**defaults)
|
||||
|
||||
|
||||
def test_subscribe_share_builds_public_payload_and_clears_cache_after_success():
|
||||
"""订阅分享隐藏本地字段,并在成功响应后触发缓存失效。"""
|
||||
sender = Mock(return_value=SimpleNamespace(status_code=200))
|
||||
clear = Mock()
|
||||
subscribe = SimpleNamespace(to_dict=lambda: {
|
||||
"name": "Demo",
|
||||
"type": "电影",
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "123",
|
||||
"username": "private",
|
||||
})
|
||||
service = _service(
|
||||
subscribe_provider=Mock(return_value=subscribe),
|
||||
subscribe_sender=sender,
|
||||
subscribe_cache_clearer=clear,
|
||||
)
|
||||
|
||||
result = service.share_subscribe(
|
||||
enabled=True,
|
||||
subscribe_id=1,
|
||||
share_title="Title",
|
||||
share_comment="Comment",
|
||||
share_user="User",
|
||||
)
|
||||
|
||||
assert result == (True, "")
|
||||
payload = sender.call_args.args[0]
|
||||
assert payload["share_uid"] == "user-1"
|
||||
assert payload["media_source"] == "themoviedb"
|
||||
assert "username" not in payload
|
||||
clear.assert_called_once_with()
|
||||
|
||||
|
||||
def test_workflow_validation_stops_before_transport():
|
||||
"""缺少动作或流程的工作流不会进入中心服务传输。"""
|
||||
sender = Mock()
|
||||
workflow = SimpleNamespace(actions=[], flows=[{"id": 1}])
|
||||
service = _service(
|
||||
workflow_provider=Mock(return_value=workflow),
|
||||
workflow_sender=sender,
|
||||
)
|
||||
|
||||
result = service.share_workflow(
|
||||
enabled=True,
|
||||
workflow_id=1,
|
||||
share_title="Title",
|
||||
share_comment="Comment",
|
||||
share_user="User",
|
||||
)
|
||||
|
||||
assert result == (False, "请分享有动作和流程的工作流")
|
||||
sender.assert_not_called()
|
||||
|
||||
|
||||
def test_async_subscribe_share_uses_async_reader_and_transport():
|
||||
"""异步分享路径不会回退到同步数据库或网络端口。"""
|
||||
subscribe = SimpleNamespace(to_dict=lambda: {
|
||||
"name": "Demo",
|
||||
"type": "电影",
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "123",
|
||||
})
|
||||
reader = AsyncMock(return_value=subscribe)
|
||||
sender = AsyncMock(return_value=SimpleNamespace(status_code=200))
|
||||
service = _service(
|
||||
async_subscribe_provider=reader,
|
||||
async_subscribe_sender=sender,
|
||||
)
|
||||
|
||||
result = asyncio.run(service.async_share_subscribe(
|
||||
enabled=True,
|
||||
subscribe_id=1,
|
||||
share_title="Title",
|
||||
share_comment="Comment",
|
||||
share_user="User",
|
||||
))
|
||||
|
||||
assert result == (True, "")
|
||||
reader.assert_awaited_once_with(1)
|
||||
sender.assert_awaited_once()
|
||||
@@ -0,0 +1,97 @@
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.site.mutation import SiteMutationCommand
|
||||
|
||||
|
||||
def _command(**overrides):
|
||||
"""构造可观察站点写用例及其依赖。"""
|
||||
repository = Mock()
|
||||
repository.get_by_id = AsyncMock(return_value=object())
|
||||
repository.get_by_domain = AsyncMock(return_value=None)
|
||||
repository.stage_create = AsyncMock()
|
||||
repository.stage_update = AsyncMock(return_value=True)
|
||||
repository.stage_delete = AsyncMock()
|
||||
repository.stage_priorities = AsyncMock()
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit = AsyncMock()
|
||||
unit_of_work.rollback = AsyncMock()
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"unit_of_work": unit_of_work,
|
||||
"auth_level_provider": Mock(return_value=2),
|
||||
"indexer_loader": AsyncMock(return_value={"name": "Demo", "public": True}),
|
||||
"domain_extractor": lambda value: "demo.example",
|
||||
"url_normalizer": lambda value: "https://demo.example/",
|
||||
"publish_updated": AsyncMock(),
|
||||
"publish_deleted": AsyncMock(),
|
||||
}
|
||||
dependencies.update(overrides)
|
||||
return SiteMutationCommand(**dependencies), dependencies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_site_commits_before_updated_event():
|
||||
"""新增站点必须先提交,再发布站点更新事件。"""
|
||||
calls = []
|
||||
command, dependencies = _command(
|
||||
unit_of_work=Mock(
|
||||
commit=AsyncMock(side_effect=lambda: calls.append("commit")),
|
||||
rollback=AsyncMock(),
|
||||
),
|
||||
publish_updated=AsyncMock(side_effect=lambda _payload: calls.append("event")),
|
||||
)
|
||||
|
||||
result = await command.create({"url": "https://demo.example/path"})
|
||||
|
||||
assert result.success is True
|
||||
assert calls == ["commit", "event"]
|
||||
payload = dependencies["repository"].stage_create.await_args.args[0]
|
||||
assert payload["domain"] == "demo.example"
|
||||
assert payload["url"] == "https://demo.example/"
|
||||
assert payload["name"] == "Demo"
|
||||
assert payload["public"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_site_returns_legacy_not_found_without_writes():
|
||||
"""更新不存在站点时保持失败响应且不产生事务或事件。"""
|
||||
repository = Mock()
|
||||
repository.get_by_id = AsyncMock(return_value=None)
|
||||
command, dependencies = _command(repository=repository)
|
||||
|
||||
result = await command.update({"id": 7, "url": "https://demo.example"})
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "站点不存在"
|
||||
dependencies["unit_of_work"].commit.assert_not_awaited()
|
||||
dependencies["publish_updated"].assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_site_commit_failure_rolls_back_without_event():
|
||||
"""删除提交失败时必须回滚且不得发送 SiteDeleted。"""
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit = AsyncMock(side_effect=RuntimeError("commit failed"))
|
||||
unit_of_work.rollback = AsyncMock()
|
||||
command, dependencies = _command(unit_of_work=unit_of_work)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
await command.delete(7)
|
||||
|
||||
unit_of_work.rollback.assert_awaited_once_with()
|
||||
dependencies["publish_deleted"].assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_priorities_uses_one_transaction():
|
||||
"""批量站点优先级必须由一个请求级事务统一提交。"""
|
||||
command, dependencies = _command()
|
||||
priorities = [{"id": 1, "pri": 2}, {"id": 2, "pri": 1}]
|
||||
|
||||
result = await command.update_priorities(priorities)
|
||||
|
||||
assert result.success is True
|
||||
dependencies["repository"].stage_priorities.assert_awaited_once_with(priorities)
|
||||
dependencies["unit_of_work"].commit.assert_awaited_once_with()
|
||||
@@ -8,6 +8,7 @@ from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
from app import schemas
|
||||
from app.schemas.mediaserver import NotExistMediaInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.testing import stub_modules
|
||||
|
||||
@@ -177,8 +178,6 @@ def _load_subscribe_chain_class():
|
||||
|
||||
words_module.WordsMatcher = _WordsMatcher
|
||||
|
||||
schemas_module = ensure_module("app.schemas", types.ModuleType("app.schemas"))
|
||||
|
||||
class _Notification:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
@@ -192,7 +191,10 @@ def _load_subscribe_chain_class():
|
||||
"tmdbid",
|
||||
"doubanid",
|
||||
"bangumiid",
|
||||
"media_source",
|
||||
"media_id",
|
||||
"season",
|
||||
"episode_group",
|
||||
"best_version",
|
||||
"save_path",
|
||||
"search_imdbid",
|
||||
@@ -255,16 +257,42 @@ def _load_subscribe_chain_class():
|
||||
def __init__(self, **kwargs):
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
schemas_module.Message = _Notification
|
||||
schemas_module.Subscribe = _SubscribeSchema
|
||||
schemas_module.NotExistMediaInfo = _NotExistMediaInfo
|
||||
schemas_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo
|
||||
schemas_module.SubscrbieInfo = _SubscrbieInfo
|
||||
schemas_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
|
||||
schemas_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
|
||||
schemas_module.MediaRecognizeConvertEventData = _MediaRecognizeConvertEventData
|
||||
schemas_module.SubscribeEpisodesRefreshEventData = _SubscribeEpisodesRefreshEventData
|
||||
schemas_module.SubscribeCompletionCheckEventData = _SubscribeCompletionCheckEventData
|
||||
mediaserver_schema_module = ensure_module(
|
||||
"app.schemas.mediaserver",
|
||||
types.ModuleType("app.schemas.mediaserver"),
|
||||
)
|
||||
mediaserver_schema_module.NotExistMediaInfo = _NotExistMediaInfo
|
||||
message_schema_module = ensure_module(
|
||||
"app.schemas.message",
|
||||
types.ModuleType("app.schemas.message"),
|
||||
)
|
||||
message_schema_module.Message = _Notification
|
||||
subscribe_schema_module = ensure_module(
|
||||
"app.schemas.subscribe",
|
||||
types.ModuleType("app.schemas.subscribe"),
|
||||
)
|
||||
subscribe_schema_module.SubscribeEpisodeInfo = _SubscribeEpisodeInfo
|
||||
subscribe_schema_module.SubscrbieInfo = _SubscrbieInfo
|
||||
subscribe_schema_module.SubscribeDownloadFileInfo = _SubscribeDownloadFileInfo
|
||||
subscribe_schema_module.SubscribeLibraryFileInfo = _SubscribeLibraryFileInfo
|
||||
workflow_schema_module = ensure_module(
|
||||
"app.schemas.workflow",
|
||||
types.ModuleType("app.schemas.workflow"),
|
||||
)
|
||||
workflow_schema_module.Subscribe = _SubscribeSchema
|
||||
event_schema_module = ensure_module(
|
||||
"app.schemas.event",
|
||||
types.ModuleType("app.schemas.event"),
|
||||
)
|
||||
event_schema_module.MediaRecognizeConvertEventData = (
|
||||
_MediaRecognizeConvertEventData
|
||||
)
|
||||
event_schema_module.SubscribeEpisodesRefreshEventData = (
|
||||
_SubscribeEpisodesRefreshEventData
|
||||
)
|
||||
event_schema_module.SubscribeCompletionCheckEventData = (
|
||||
_SubscribeCompletionCheckEventData
|
||||
)
|
||||
|
||||
logger_module = ensure_module("app.runtime.log", types.ModuleType("app.runtime.log"))
|
||||
|
||||
@@ -2196,14 +2224,14 @@ class SubscribeProgressEntrypointTest(TestCase):
|
||||
subscribe = self._build_subscribe(best_version=0, note=[1])
|
||||
missing_all = {
|
||||
"tmdb:10001": {
|
||||
1: self.module.schemas.NotExistMediaInfo(
|
||||
1: NotExistMediaInfo(
|
||||
season=1, episodes=[], total_episode=5, start_episode=1
|
||||
)
|
||||
}
|
||||
}
|
||||
missing_some = {
|
||||
"tmdb:10001": {
|
||||
1: self.module.schemas.NotExistMediaInfo(
|
||||
1: NotExistMediaInfo(
|
||||
season=1, episodes=[2, 4], total_episode=5, start_episode=1
|
||||
)
|
||||
}
|
||||
@@ -2508,7 +2536,7 @@ class SubscribeProgressEntrypointTest(TestCase):
|
||||
)
|
||||
no_exists = {
|
||||
"tmdb:10001": {
|
||||
1: self.module.schemas.NotExistMediaInfo(
|
||||
1: NotExistMediaInfo(
|
||||
season=1, episodes=[2, 4], total_episode=5, start_episode=1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""按媒体身份批量删除订阅的应用用例测试。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.subscription.delete import (
|
||||
SubscribeDeletionActor,
|
||||
SubscribeDeletionCandidate,
|
||||
)
|
||||
from app.application.subscription.identity import (
|
||||
DeleteSubscriptionsByIdentityCommand,
|
||||
)
|
||||
from app.schemas.types import MediaSource
|
||||
|
||||
|
||||
class _Repository:
|
||||
"""记录批量订阅删除顺序的仓储替身。"""
|
||||
|
||||
def __init__(self, candidates, calls):
|
||||
"""保存候选订阅和共享调用序列。"""
|
||||
self.candidates = candidates
|
||||
self.calls = calls
|
||||
|
||||
async def list_candidates_by_identity(self, *args):
|
||||
"""记录媒体身份查询参数并返回候选订阅。"""
|
||||
self.calls.append(("list", *args))
|
||||
return self.candidates
|
||||
|
||||
async def stage_delete(self, subscribe_id):
|
||||
"""记录待删除订阅。"""
|
||||
self.calls.append(("delete", subscribe_id))
|
||||
|
||||
|
||||
class _UnitOfWork:
|
||||
"""可注入提交异常的批量事务替身。"""
|
||||
|
||||
def __init__(self, calls, commit_error=None):
|
||||
"""保存共享调用序列与可选提交异常。"""
|
||||
self.calls = calls
|
||||
self.commit_error = commit_error
|
||||
|
||||
async def commit(self):
|
||||
"""记录提交并按需失败。"""
|
||||
self.calls.append(("commit",))
|
||||
if self.commit_error:
|
||||
raise self.commit_error
|
||||
|
||||
async def rollback(self):
|
||||
"""记录回滚。"""
|
||||
self.calls.append(("rollback",))
|
||||
|
||||
|
||||
def _candidate(subscribe_id, username):
|
||||
"""构造批量删除候选订阅。"""
|
||||
return SubscribeDeletionCandidate(
|
||||
subscribe_id=subscribe_id,
|
||||
username=username,
|
||||
event_payload={
|
||||
"id": subscribe_id,
|
||||
"username": username,
|
||||
"media_source": "tmdb",
|
||||
"media_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _command(candidates, calls, commit_error=None, failing_event_id=None):
|
||||
"""构造带可观察事件错误处理的批量删除用例。"""
|
||||
async def publish(subscribe_id, payload):
|
||||
"""记录事件并按订阅编号注入失败。"""
|
||||
calls.append(("event", subscribe_id, payload))
|
||||
if subscribe_id == failing_event_id:
|
||||
raise RuntimeError("event failed")
|
||||
|
||||
def handle_error(subscribe_id, error):
|
||||
"""记录被隔离的单条事件异常。"""
|
||||
calls.append(("event_error", subscribe_id, str(error)))
|
||||
|
||||
return DeleteSubscriptionsByIdentityCommand(
|
||||
repository=_Repository(candidates, calls),
|
||||
unit_of_work=_UnitOfWork(calls, commit_error),
|
||||
publish_deleted=publish,
|
||||
handle_event_error=handle_error,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_filters_owner_and_commits_before_events():
|
||||
"""普通用户只删除自己的候选,并在提交后发送事件。"""
|
||||
calls = []
|
||||
command = _command([_candidate(1, "bob"), _candidate(2, "alice")], calls)
|
||||
|
||||
deleted = await command.execute(
|
||||
MediaSource.TMDB,
|
||||
"123",
|
||||
1,
|
||||
None,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert deleted == 1
|
||||
assert [call[0] for call in calls] == ["list", "delete", "commit", "event"]
|
||||
assert calls[1] == ("delete", 2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_commits_even_when_nothing_matches():
|
||||
"""无匹配订阅时仍保持历史上的空事务提交行为。"""
|
||||
calls = []
|
||||
command = _command([], calls)
|
||||
|
||||
deleted = await command.execute(
|
||||
MediaSource.TMDB,
|
||||
"123",
|
||||
None,
|
||||
None,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert deleted == 0
|
||||
assert [call[0] for call in calls] == ["list", "commit"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_commit_failure_rolls_back_without_events():
|
||||
"""批量提交失败必须回滚且不发送任何删除事件。"""
|
||||
calls = []
|
||||
command = _command(
|
||||
[_candidate(1, "alice")],
|
||||
calls,
|
||||
commit_error=RuntimeError("commit failed"),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
await command.execute(
|
||||
MediaSource.TMDB,
|
||||
"123",
|
||||
None,
|
||||
None,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == ["list", "delete", "commit", "rollback"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_delete_isolates_one_event_failure_and_continues():
|
||||
"""单条事件失败只记录错误,后续已提交订阅仍继续发事件。"""
|
||||
calls = []
|
||||
command = _command(
|
||||
[_candidate(1, "alice"), _candidate(2, "alice")],
|
||||
calls,
|
||||
failing_event_id=1,
|
||||
)
|
||||
|
||||
deleted = await command.execute(
|
||||
MediaSource.TMDB,
|
||||
"123",
|
||||
None,
|
||||
None,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert deleted == 2
|
||||
assert [call[0] for call in calls] == [
|
||||
"list",
|
||||
"delete",
|
||||
"delete",
|
||||
"commit",
|
||||
"event",
|
||||
"event_error",
|
||||
"event",
|
||||
]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""订阅删除应用用例的事务、权限与副作用时序测试。"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.subscription.delete import (
|
||||
DeleteSubscribeCommand,
|
||||
SubscribeDeletionActor,
|
||||
SubscribeDeletionCandidate,
|
||||
)
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.oper.subscribe import SubscribeOper
|
||||
|
||||
|
||||
class _Repository:
|
||||
"""记录订阅删除用例数据访问顺序的仓储替身。"""
|
||||
|
||||
def __init__(self, candidate, calls):
|
||||
"""保存候选订阅和共享调用序列。"""
|
||||
self.candidate = candidate
|
||||
self.calls = calls
|
||||
|
||||
async def get_candidate(self, subscribe_id):
|
||||
"""返回预设候选订阅。"""
|
||||
self.calls.append(("get", subscribe_id))
|
||||
return self.candidate
|
||||
|
||||
async def stage_delete(self, subscribe_id):
|
||||
"""记录待删除的订阅编号。"""
|
||||
self.calls.append(("delete", subscribe_id))
|
||||
|
||||
|
||||
class _UnitOfWork:
|
||||
"""可注入提交异常的事务替身。"""
|
||||
|
||||
def __init__(self, calls, commit_error=None):
|
||||
"""保存共享调用序列与可选提交异常。"""
|
||||
self.calls = calls
|
||||
self.commit_error = commit_error
|
||||
|
||||
async def commit(self):
|
||||
"""记录提交并按需抛出异常。"""
|
||||
self.calls.append(("commit",))
|
||||
if self.commit_error:
|
||||
raise self.commit_error
|
||||
|
||||
async def rollback(self):
|
||||
"""记录回滚。"""
|
||||
self.calls.append(("rollback",))
|
||||
|
||||
|
||||
def _candidate(username="alice"):
|
||||
"""构造带完整事件身份字段的订阅删除候选。"""
|
||||
return SubscribeDeletionCandidate(
|
||||
subscribe_id=7,
|
||||
username=username,
|
||||
event_payload={
|
||||
"id": 7,
|
||||
"username": username,
|
||||
"media_source": "tmdb",
|
||||
"media_id": "123",
|
||||
"season": 2,
|
||||
"name": "测试订阅",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _command(candidate, calls, commit_error=None, event_error=None, report_error=None):
|
||||
"""构造可观察事件与上报失败的订阅删除用例。"""
|
||||
async def publish(subscribe_id, subscribe_info):
|
||||
"""记录删除事件并按需失败。"""
|
||||
calls.append(("event", subscribe_id, subscribe_info))
|
||||
if event_error:
|
||||
raise event_error
|
||||
|
||||
def report(payload):
|
||||
"""记录删除统计并按需失败。"""
|
||||
calls.append(("report", payload))
|
||||
if report_error:
|
||||
raise report_error
|
||||
|
||||
return DeleteSubscribeCommand(
|
||||
repository=_Repository(candidate, calls),
|
||||
unit_of_work=_UnitOfWork(calls, commit_error),
|
||||
publish_deleted=publish,
|
||||
report_deleted=report,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owner_delete_commits_before_event_and_report():
|
||||
"""owner 删除成功时必须先提交,再按原顺序发送事件和上报。"""
|
||||
calls = []
|
||||
command = _command(_candidate(), calls)
|
||||
|
||||
deleted = await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert deleted is True
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
|
||||
assert calls[3][2] == _candidate().event_payload
|
||||
assert calls[4][1] == {
|
||||
"media_source": "tmdb",
|
||||
"media_id": "123",
|
||||
"season": 2,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("candidate", [None, _candidate("bob"), _candidate(None)])
|
||||
async def test_regular_user_cannot_delete_missing_other_or_legacy_subscribe(candidate):
|
||||
"""普通用户对不存在、他人和 legacy 订阅保持无痕成功语义。"""
|
||||
calls = []
|
||||
command = _command(candidate, calls)
|
||||
|
||||
deleted = await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert deleted is False
|
||||
assert calls == [("get", 7)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_superuser_can_delete_other_users_subscribe():
|
||||
"""超级用户保留全局订阅删除权限。"""
|
||||
calls = []
|
||||
command = _command(_candidate("bob"), calls)
|
||||
|
||||
deleted = await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="admin", is_superuser=True),
|
||||
)
|
||||
|
||||
assert deleted is True
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_failure_rolls_back_without_event_or_report():
|
||||
"""提交失败必须回滚,且不得发送成功事件或统计上报。"""
|
||||
calls = []
|
||||
command = _command(_candidate(), calls, commit_error=RuntimeError("commit failed"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "rollback"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_failure_happens_after_commit_and_stops_report():
|
||||
"""事件失败保持原有传播语义,但事务必须已经提交且不得继续上报。"""
|
||||
calls = []
|
||||
command = _command(_candidate(), calls, event_error=RuntimeError("event failed"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="event failed"):
|
||||
await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "event"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_failure_happens_after_commit_and_event():
|
||||
"""上报失败保持原有传播语义,且不得改变已经提交和发出的事件。"""
|
||||
calls = []
|
||||
command = _command(_candidate(), calls, report_error=RuntimeError("report failed"))
|
||||
|
||||
with pytest.raises(RuntimeError, match="report failed"):
|
||||
await command.execute(
|
||||
7,
|
||||
SubscribeDeletionActor(username="alice", is_superuser=False),
|
||||
)
|
||||
|
||||
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_candidate_uses_loaded_orm_snapshot(monkeypatch):
|
||||
"""DB 适配器只向应用层暴露权限字段和完整列快照。"""
|
||||
subscribe = Subscribe(
|
||||
id=7,
|
||||
username="alice",
|
||||
name="测试订阅",
|
||||
media_source="tmdb",
|
||||
media_id="123",
|
||||
season=2,
|
||||
)
|
||||
|
||||
async def async_get(_self, subscribe_id):
|
||||
"""返回无需真实数据库的订阅模型。"""
|
||||
assert subscribe_id == 7
|
||||
return subscribe
|
||||
|
||||
monkeypatch.setattr(SubscribeOper, "async_get", async_get)
|
||||
|
||||
candidate = await SubscribeOper(object()).get_candidate(7)
|
||||
|
||||
assert candidate is not None
|
||||
assert candidate.subscribe_id == 7
|
||||
assert candidate.username == "alice"
|
||||
assert candidate.event_payload["id"] == 7
|
||||
assert candidate.event_payload["media_source"] == "tmdb"
|
||||
assert candidate.event_payload["media_id"] == "123"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_stage_delete_does_not_commit():
|
||||
"""真实仓储只登记删除,提交必须由请求级 UnitOfWork 执行。"""
|
||||
session = type("SessionStub", (), {})()
|
||||
session.execute = AsyncMock()
|
||||
session.commit = AsyncMock()
|
||||
|
||||
await SubscribeOper(session).stage_delete(7)
|
||||
|
||||
session.execute.assert_awaited_once()
|
||||
session.commit.assert_not_awaited()
|
||||
@@ -81,6 +81,26 @@ class SubscribeEndpointTest(TestCase):
|
||||
|
||||
self.assertEqual(getattr(result, "id", None), expected_id)
|
||||
|
||||
def test_delete_subscribe_delegates_identity_without_database_access(self):
|
||||
"""按 ID 删除端点只映射用户身份,并保持不存在时也返回成功。"""
|
||||
from app.api.endpoints.subscribe import delete_subscribe
|
||||
|
||||
command = SimpleNamespace(execute=AsyncMock(return_value=False))
|
||||
response = asyncio.run(
|
||||
delete_subscribe(
|
||||
subscribe_id=7,
|
||||
command=command,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(response.success)
|
||||
command.execute.assert_awaited_once()
|
||||
subscribe_id, actor = command.execute.await_args.args
|
||||
self.assertEqual(subscribe_id, 7)
|
||||
self.assertEqual(actor.username, "alice")
|
||||
self.assertFalse(actor.is_superuser)
|
||||
|
||||
def test_manage_permission_does_not_allow_cross_user_update(self):
|
||||
"""
|
||||
manage 权限不等于跨用户订阅管理权限,普通用户不能修改他人或 legacy 订阅。
|
||||
@@ -512,99 +532,76 @@ class SubscribeEndpointTest(TestCase):
|
||||
|
||||
def test_delete_subscribe_by_media_identity_deletes_owner_candidate(self):
|
||||
"""
|
||||
按媒体删除订阅时,应在候选集合中删除当前用户自己的订阅。
|
||||
按媒体删除端点应把媒体身份和当前用户交给应用命令。
|
||||
"""
|
||||
from app.api.endpoints.subscribe import delete_subscribe_by_media_identity
|
||||
|
||||
other = _EndpointSubscribe(
|
||||
id=15, username="bob", media_source="douban", media_id="douban-1"
|
||||
)
|
||||
own = _EndpointSubscribe(
|
||||
id=16, username="alice", media_source="douban", media_id="douban-1"
|
||||
)
|
||||
db = _EndpointAsyncDb()
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_media_identity",
|
||||
new=AsyncMock(return_value=[other, own]),
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.build_subscribe_event_payload",
|
||||
return_value={"id": 16, "media_source": "douban", "media_id": "douban-1"},
|
||||
), patch(
|
||||
"app.api.endpoints.subscribe.eventmanager.async_send_event",
|
||||
new=AsyncMock(),
|
||||
) as send_event:
|
||||
response = asyncio.run(
|
||||
delete_subscribe_by_media_identity(
|
||||
media_id="douban-1",
|
||||
media_source=MediaSource.Douban,
|
||||
db=db,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
command = SimpleNamespace(execute=AsyncMock(return_value=1))
|
||||
response = asyncio.run(
|
||||
delete_subscribe_by_media_identity(
|
||||
media_id="douban-1",
|
||||
media_source=MediaSource.Douban,
|
||||
command=command,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(response.success)
|
||||
self.assertEqual(db.deleted, [own])
|
||||
send_event.assert_awaited_once()
|
||||
command.execute.assert_awaited_once()
|
||||
media_source, media_id, season, music_type, actor = command.execute.await_args.args
|
||||
self.assertEqual(media_source, MediaSource.Douban)
|
||||
self.assertEqual(media_id, "douban-1")
|
||||
self.assertIsNone(season)
|
||||
self.assertIsNone(music_type)
|
||||
self.assertEqual(actor.username, "alice")
|
||||
self.assertFalse(actor.is_superuser)
|
||||
|
||||
def test_delete_subscribe_by_media_identity_forwards_music_entity(self):
|
||||
"""取消专辑订阅时必须把实体类型传给统一身份查询。"""
|
||||
from app.api.endpoints.subscribe import delete_subscribe_by_media_identity
|
||||
|
||||
db = _EndpointAsyncDb()
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.list_subscribes_by_media_identity",
|
||||
new=AsyncMock(return_value=[]),
|
||||
) as list_by_key:
|
||||
response = asyncio.run(
|
||||
delete_subscribe_by_media_identity(
|
||||
media_id="release-group-1",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
music_type="album",
|
||||
db=db,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
command = SimpleNamespace(execute=AsyncMock(return_value=0))
|
||||
response = asyncio.run(
|
||||
delete_subscribe_by_media_identity(
|
||||
media_id="release-group-1",
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
music_type="album",
|
||||
command=command,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(response.success)
|
||||
list_by_key.assert_awaited_once_with(
|
||||
db,
|
||||
command.execute.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
command.execute.await_args.args[:4],
|
||||
(
|
||||
MediaSource.MusicBrainz,
|
||||
"release-group-1",
|
||||
None,
|
||||
"album",
|
||||
),
|
||||
)
|
||||
|
||||
def test_search_subscribes_regular_user_schedules_only_owned_rows(self):
|
||||
"""
|
||||
普通用户批量搜索只按自己的订阅 ID 入队。
|
||||
普通用户批量搜索把用户身份交给应用命令。
|
||||
"""
|
||||
from app.api.endpoints.subscribe import search_subscribes
|
||||
|
||||
background_tasks = _EndpointBackgroundTasks()
|
||||
owned = [
|
||||
_EndpointSubscribe(id=17, username="alice", state="R"),
|
||||
_EndpointSubscribe(id=18, username="alice", state="R"),
|
||||
]
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.subscribe.Subscribe.async_list_by_username",
|
||||
new=AsyncMock(return_value=owned),
|
||||
), patch("app.api.endpoints.subscribe.Scheduler") as scheduler_cls:
|
||||
response = asyncio.run(
|
||||
search_subscribes(
|
||||
background_tasks=background_tasks,
|
||||
db=object(),
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
command = SimpleNamespace(execute=AsyncMock(return_value=True))
|
||||
response = asyncio.run(
|
||||
search_subscribes(
|
||||
command=command,
|
||||
current_user=_EndpointUser(name="alice", is_superuser=False),
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(response.success)
|
||||
self.assertEqual(
|
||||
[task["kwargs"]["sid"] for task in background_tasks.tasks],
|
||||
[17, 18],
|
||||
)
|
||||
self.assertEqual(scheduler_cls.return_value.start.call_count, 0)
|
||||
command.execute.assert_awaited_once()
|
||||
actor = command.execute.await_args.args[0]
|
||||
self.assertEqual(actor.username, "alice")
|
||||
self.assertFalse(actor.is_superuser)
|
||||
|
||||
def test_subscribe_files_hides_other_user_row(self):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import pytest
|
||||
|
||||
from app.application.subscription.delete import SubscribeDeletionCandidate
|
||||
from app.application.subscription.search import (
|
||||
SearchSubscriptionsCommand,
|
||||
SubscribeSearchActor,
|
||||
)
|
||||
|
||||
|
||||
class _Repository:
|
||||
"""提供手工订阅搜索测试需要的归属和列表数据。"""
|
||||
|
||||
def __init__(self, candidate=None, subscribe_ids=None):
|
||||
"""保存预设单条候选和批量编号。"""
|
||||
self.candidate = candidate
|
||||
self.subscribe_ids = subscribe_ids or []
|
||||
|
||||
async def get_candidate(self, _subscribe_id):
|
||||
"""返回预设订阅候选。"""
|
||||
return self.candidate
|
||||
|
||||
async def list_search_ids(self, username, state):
|
||||
"""校验普通用户搜索状态并返回预设编号。"""
|
||||
assert username == "alice"
|
||||
assert state == "R"
|
||||
return self.subscribe_ids
|
||||
|
||||
|
||||
def _candidate(username):
|
||||
"""构造只包含归属信息的订阅候选。"""
|
||||
return SubscribeDeletionCandidate(
|
||||
subscribe_id=7,
|
||||
username=username,
|
||||
event_payload={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_superuser_search_all_uses_single_global_scheduler_request():
|
||||
"""管理员搜索全部订阅时保持一次 state=R 的全局调度语义。"""
|
||||
scheduled = []
|
||||
command = SearchSubscriptionsCommand(
|
||||
repository=_Repository(),
|
||||
schedule_search=lambda sid, state: scheduled.append((sid, state)),
|
||||
)
|
||||
|
||||
assert await command.execute(
|
||||
SubscribeSearchActor(username="admin", is_superuser=True)
|
||||
) is True
|
||||
assert scheduled == [(None, "R")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_user_search_all_schedules_only_owned_subscriptions():
|
||||
"""普通用户搜索全部时逐条提交仓储已按归属过滤的订阅。"""
|
||||
scheduled = []
|
||||
command = SearchSubscriptionsCommand(
|
||||
repository=_Repository(subscribe_ids=[2, 5]),
|
||||
schedule_search=lambda sid, state: scheduled.append((sid, state)),
|
||||
)
|
||||
|
||||
assert await command.execute(
|
||||
SubscribeSearchActor(username="alice", is_superuser=False)
|
||||
) is True
|
||||
assert scheduled == [(2, None), (5, None)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_targeted_search_rejects_missing_or_other_users_subscription():
|
||||
"""单条搜索不得泄漏订阅是否属于其他普通用户。"""
|
||||
scheduled = []
|
||||
command = SearchSubscriptionsCommand(
|
||||
repository=_Repository(candidate=_candidate("bob")),
|
||||
schedule_search=lambda sid, state: scheduled.append((sid, state)),
|
||||
)
|
||||
|
||||
assert await command.execute(
|
||||
SubscribeSearchActor(username="alice", is_superuser=False),
|
||||
subscribe_id=7,
|
||||
) is False
|
||||
assert scheduled == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_targeted_search_schedules_accessible_subscription():
|
||||
"""归属用户搜索单条订阅时提交历史兼容参数。"""
|
||||
scheduled = []
|
||||
command = SearchSubscriptionsCommand(
|
||||
repository=_Repository(candidate=_candidate("alice")),
|
||||
schedule_search=lambda sid, state: scheduled.append((sid, state)),
|
||||
)
|
||||
|
||||
assert await command.execute(
|
||||
SubscribeSearchActor(username="alice", is_superuser=False),
|
||||
subscribe_id=7,
|
||||
) is True
|
||||
assert scheduled == [(7, None)]
|
||||
@@ -0,0 +1,96 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.application.subscription.query import SubscriptionQueryService
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.domain.context import MediaInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
|
||||
|
||||
def test_subscription_query_service_builds_complete_exists_identity() -> None:
|
||||
"""存在性查询必须保留媒体、音乐实体、季和剧集组全部身份维度。"""
|
||||
repository = Mock()
|
||||
repository.exists.return_value = True
|
||||
service = SubscriptionQueryService(repository)
|
||||
media = MediaInfo(
|
||||
type=MediaType.TV,
|
||||
title="Demo",
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="123",
|
||||
episode_group="group-1",
|
||||
)
|
||||
|
||||
assert service.exists(media, SimpleNamespace(begin_season=2)) is True
|
||||
repository.exists.assert_called_once_with(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="123",
|
||||
music_type=None,
|
||||
season=2,
|
||||
episode_group="group-1",
|
||||
)
|
||||
|
||||
|
||||
def test_subscription_query_service_filters_source_and_music_state() -> None:
|
||||
"""来源查询不透传展示字段,音乐状态查询保持 R/P 联合列表语义。"""
|
||||
repository = Mock()
|
||||
expected = SimpleNamespace(id=1)
|
||||
repository.get_by.return_value = expected
|
||||
repository.list.return_value = [
|
||||
SimpleNamespace(type=MediaType.MOVIE.value),
|
||||
SimpleNamespace(type=MediaType.MUSIC.value),
|
||||
]
|
||||
service = SubscriptionQueryService(repository)
|
||||
|
||||
result = service.get_by_source({
|
||||
"id": 1,
|
||||
"name": "Demo",
|
||||
"type": MediaType.TV.value,
|
||||
"season": 1,
|
||||
"media_source": MediaSource.TMDB,
|
||||
"media_id": "123",
|
||||
"music_type": None,
|
||||
})
|
||||
|
||||
assert result is expected
|
||||
repository.get_by.assert_called_once_with(
|
||||
type=MediaType.TV.value,
|
||||
season=1,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="123",
|
||||
music_type=None,
|
||||
)
|
||||
assert service.has_music("R,P") is True
|
||||
repository.list.assert_called_once_with("R,P")
|
||||
|
||||
|
||||
def test_subscribe_chain_facade_delegates_three_query_slices() -> None:
|
||||
"""SubscribeChain 保持三个公开方法签名并仅负责来源解析和结果转发。"""
|
||||
service = Mock()
|
||||
service.exists.return_value = True
|
||||
service.get_by_source.return_value = SimpleNamespace(id=7)
|
||||
service.has_music.return_value = True
|
||||
media = MediaInfo(
|
||||
type=MediaType.MOVIE,
|
||||
title="Demo",
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id="123",
|
||||
)
|
||||
source = (
|
||||
'Subscribe|{"type":"电影","season":null,'
|
||||
'"media_source":"themoviedb","media_id":"123"}'
|
||||
)
|
||||
|
||||
with patch.object(SubscribeChain, "_subscription_query", return_value=service):
|
||||
chain = object.__new__(SubscribeChain)
|
||||
assert chain.exists(media) is True
|
||||
assert chain.get_subscribe_by_source(source).id == 7
|
||||
assert chain.has_music_subscribe() is True
|
||||
|
||||
service.exists.assert_called_once_with(media, None)
|
||||
service.get_by_source.assert_called_once_with({
|
||||
"type": "电影",
|
||||
"season": None,
|
||||
"media_source": "themoviedb",
|
||||
"media_id": "123",
|
||||
})
|
||||
service.has_music.assert_called_once_with("R,P")
|
||||
@@ -7,11 +7,13 @@ tests/test_transfer_history_gate.py 逐项覆盖,本文件换一个角度:
|
||||
以及删除整理记录会让预算重新满额,贴近真实使用场景。
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app import schemas
|
||||
from app.runtime.config import settings
|
||||
from app.application.history import (
|
||||
HistoryGateAction,
|
||||
TransferHistoryMutationCommand,
|
||||
clear_transfer_failures,
|
||||
evaluate_history_gate,
|
||||
failed_retry_count,
|
||||
@@ -92,10 +94,8 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
app/api/endpoints/history.py::delete_transfer_history 是用户删除整理记录的入口,
|
||||
删除时应连带清空失败重试计数,否则重整仍会受上一轮次数限制。
|
||||
|
||||
该端点依赖 SQLAlchemy Session 与鉴权依赖,这里按仓库内既有做法(参见
|
||||
tests/test_manual_transfer_history.py 对 app.api.endpoints.transfer 端点的用法)
|
||||
直接以关键字参数调用端点函数本身,绕开 FastAPI 的依赖注入,只替换端点内部
|
||||
实际用到的 TransferHistory.get / TransferHistory.delete 两个类方法。
|
||||
该端点依赖应用命令与鉴权依赖,这里直接注入可观察命令,验证 HTTP 映射与
|
||||
应用层清理失败计数的协作,同时避免重新耦合已迁出的数据库会话参数。
|
||||
"""
|
||||
from app.api.endpoints.history import delete_transfer_history
|
||||
|
||||
@@ -109,10 +109,17 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
src_fileitem=None,
|
||||
download_hash=None,
|
||||
)
|
||||
monkeypatch.setattr("app.api.endpoints.history.TransferHistory.get",
|
||||
lambda db, history_id: history)
|
||||
monkeypatch.setattr("app.api.endpoints.history.TransferHistory.delete",
|
||||
lambda db, history_id: None)
|
||||
repository = Mock()
|
||||
repository.get.return_value = history
|
||||
command = TransferHistoryMutationCommand(
|
||||
repository=repository,
|
||||
download_repository=Mock(),
|
||||
unit_of_work=Mock(),
|
||||
file_item_factory=lambda payload: SimpleNamespace(**payload),
|
||||
delete_media_file=Mock(return_value=True),
|
||||
publish_download_file_deleted=Mock(),
|
||||
clear_failures=clear_transfer_failures,
|
||||
)
|
||||
|
||||
_reset_failed_retries(src_path, storage)
|
||||
try:
|
||||
@@ -124,7 +131,7 @@ def test_delete_transfer_history_endpoint_clears_retry_count(monkeypatch):
|
||||
history_in=schemas.TransferHistory(id=101),
|
||||
deletesrc=False,
|
||||
deletedest=False,
|
||||
db=object(),
|
||||
command=command,
|
||||
_="token",
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from app.application.transfer import TransferQueueService
|
||||
from app.schemas.file import FileItem
|
||||
|
||||
from tests.test_transfer_job_manager import make_task
|
||||
|
||||
|
||||
def _service(**overrides):
|
||||
"""构造可观测整理队列服务及其默认依赖。"""
|
||||
dependencies = {
|
||||
"register_task": Mock(return_value=True),
|
||||
"enqueue": Mock(),
|
||||
"before_enqueue": Mock(),
|
||||
"after_enqueue": Mock(),
|
||||
"remove_task": Mock(),
|
||||
"list_tasks": Mock(return_value=["job"]),
|
||||
"expire_tasks": Mock(),
|
||||
}
|
||||
dependencies.update(overrides)
|
||||
return TransferQueueService(**dependencies), dependencies
|
||||
|
||||
|
||||
def test_transfer_queue_service_put_preserves_registration_order():
|
||||
"""入队必须先登记视图,再登记批次、写队列并落盘。"""
|
||||
calls = []
|
||||
service, _ = _service(
|
||||
register_task=lambda _task: calls.append("register") or True,
|
||||
before_enqueue=lambda _task: calls.append("batch"),
|
||||
enqueue=lambda _item: calls.append("queue"),
|
||||
after_enqueue=lambda _task: calls.append("pending"),
|
||||
)
|
||||
|
||||
assert service.put(make_task(1), Mock()) is True
|
||||
assert calls == ["register", "batch", "queue", "pending"]
|
||||
|
||||
|
||||
def test_transfer_queue_service_rejects_duplicate_without_side_effects():
|
||||
"""作业视图拒绝重复任务后不得继续产生队列副作用。"""
|
||||
service, dependencies = _service(register_task=Mock(return_value=False))
|
||||
|
||||
assert service.put(make_task(1), Mock()) is False
|
||||
dependencies["before_enqueue"].assert_not_called()
|
||||
dependencies["enqueue"].assert_not_called()
|
||||
dependencies["after_enqueue"].assert_not_called()
|
||||
|
||||
|
||||
def test_transfer_queue_service_lists_and_removes_through_ports():
|
||||
"""队列查询先清理失活任务,移除操作只委托作业视图。"""
|
||||
service, dependencies = _service()
|
||||
fileitem = FileItem(storage="local", path="/tmp/demo.mkv", type="file")
|
||||
|
||||
assert service.list() == ["job"]
|
||||
service.remove(fileitem)
|
||||
|
||||
dependencies["expire_tasks"].assert_called_once_with()
|
||||
dependencies["list_tasks"].assert_called_once_with()
|
||||
dependencies["remove_task"].assert_called_once_with(fileitem)
|
||||
@@ -0,0 +1,231 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.workflow import (
|
||||
WorkflowDefinitionCommand,
|
||||
WorkflowMutationCommand,
|
||||
)
|
||||
|
||||
|
||||
def _workflow(trigger_type="timer", timer="0 0 * * *", event_type="DownloadAdded"):
|
||||
"""构造工作流写用例使用的最小快照。"""
|
||||
return SimpleNamespace(
|
||||
id=7,
|
||||
trigger_type=trigger_type,
|
||||
timer=timer,
|
||||
event_type=event_type,
|
||||
)
|
||||
|
||||
|
||||
def _command(workflow=None, commit_error=None):
|
||||
"""构造可观察工作流事务与运行时副作用的命令。"""
|
||||
repository = Mock()
|
||||
repository.get = Mock(return_value=workflow)
|
||||
repository.stage_state = Mock(return_value=True)
|
||||
repository.stage_update = Mock(return_value=workflow)
|
||||
repository.stage_delete = Mock()
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit = Mock(side_effect=commit_error)
|
||||
unit_of_work.rollback = Mock()
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"unit_of_work": unit_of_work,
|
||||
"add_timer": Mock(),
|
||||
"remove_timer": Mock(),
|
||||
"load_event": Mock(),
|
||||
"remove_event": Mock(),
|
||||
"refresh_event": Mock(),
|
||||
"stop_running": Mock(),
|
||||
"delete_cache": Mock(),
|
||||
}
|
||||
return WorkflowMutationCommand(**dependencies), dependencies
|
||||
|
||||
|
||||
def test_start_timer_workflow_commits_before_registering_job():
|
||||
"""启用定时工作流必须先提交 W 状态,再登记定时任务。"""
|
||||
calls = []
|
||||
command, dependencies = _command(_workflow())
|
||||
dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit")
|
||||
dependencies["add_timer"].side_effect = lambda _workflow: calls.append("timer")
|
||||
|
||||
result = command.start(7)
|
||||
|
||||
assert result.success is True
|
||||
assert calls == ["commit", "timer"]
|
||||
dependencies["repository"].stage_state.assert_called_once_with(7, "W")
|
||||
|
||||
|
||||
def test_start_rejects_invalid_trigger_without_transaction():
|
||||
"""未知触发类型不得更新数据库或注册运行时触发器。"""
|
||||
command, dependencies = _command(_workflow(trigger_type="unknown"))
|
||||
|
||||
result = command.start(7)
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "工作流触发类型不支持"
|
||||
dependencies["unit_of_work"].commit.assert_not_called()
|
||||
|
||||
|
||||
def test_pause_event_workflow_commits_before_runtime_cleanup():
|
||||
"""停用事件工作流必须提交 P 状态后再移除事件和停止执行。"""
|
||||
calls = []
|
||||
command, dependencies = _command(_workflow(trigger_type="event", timer=None))
|
||||
dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit")
|
||||
dependencies["remove_event"].side_effect = lambda *_args: calls.append("event")
|
||||
dependencies["stop_running"].side_effect = lambda _id: calls.append("stop")
|
||||
|
||||
result = command.pause(7)
|
||||
|
||||
assert result.success is True
|
||||
assert calls == ["commit", "event", "stop"]
|
||||
|
||||
|
||||
def test_delete_commit_failure_rolls_back_without_runtime_side_effects():
|
||||
"""删除提交失败必须回滚,且不得删除缓存或运行时触发器。"""
|
||||
command, dependencies = _command(
|
||||
_workflow(),
|
||||
commit_error=RuntimeError("commit failed"),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
command.delete(7)
|
||||
|
||||
dependencies["unit_of_work"].rollback.assert_called_once_with()
|
||||
dependencies["delete_cache"].assert_not_called()
|
||||
dependencies["remove_timer"].assert_not_called()
|
||||
|
||||
|
||||
def test_update_refreshes_timer_and_event_after_commit():
|
||||
"""更新工作流提交后重建定时器并刷新事件注册。"""
|
||||
workflow = _workflow()
|
||||
command, dependencies = _command(workflow)
|
||||
|
||||
result = command.update({"id": 7, "name": "updated"})
|
||||
|
||||
assert result.success is True
|
||||
dependencies["repository"].stage_update.assert_called_once()
|
||||
dependencies["unit_of_work"].commit.assert_called_once_with()
|
||||
dependencies["remove_timer"].assert_called_once_with(workflow)
|
||||
dependencies["add_timer"].assert_called_once_with(workflow)
|
||||
dependencies["refresh_event"].assert_called_once_with(workflow)
|
||||
|
||||
|
||||
def _definition_command(*, existing=None, commit_error=None, report_fork=None):
|
||||
"""构造可观察异步工作流定义事务的命令。"""
|
||||
repository = Mock()
|
||||
repository.async_get_by_name = AsyncMock(return_value=existing)
|
||||
repository.async_get = AsyncMock(return_value=existing)
|
||||
repository.stage_create = AsyncMock(return_value=SimpleNamespace(id=8))
|
||||
repository.stage_reset = AsyncMock(return_value=existing)
|
||||
unit_of_work = Mock()
|
||||
unit_of_work.commit = AsyncMock(side_effect=commit_error)
|
||||
unit_of_work.rollback = AsyncMock()
|
||||
dependencies = {
|
||||
"repository": repository,
|
||||
"unit_of_work": unit_of_work,
|
||||
"stop_running": Mock(),
|
||||
"delete_cache": Mock(),
|
||||
"report_fork": report_fork or AsyncMock(),
|
||||
}
|
||||
return WorkflowDefinitionCommand(**dependencies), dependencies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_workflow_applies_defaults_and_commits_once():
|
||||
"""创建工作流由应用用例补齐默认状态并统一提交。"""
|
||||
command, dependencies = _definition_command()
|
||||
|
||||
result = await command.create({"name": "Demo", "state": None})
|
||||
|
||||
assert result.success is True
|
||||
payload = dependencies["repository"].stage_create.await_args.args[0]
|
||||
assert payload["trigger_type"] == "timer"
|
||||
assert payload["state"] == "P"
|
||||
assert payload["add_time"]
|
||||
dependencies["unit_of_work"].commit.assert_awaited_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_name_has_no_transaction():
|
||||
"""名称重复时不得暂存或提交工作流。"""
|
||||
command, dependencies = _definition_command(existing=SimpleNamespace(id=1))
|
||||
|
||||
result = await command.create({"name": "Demo"})
|
||||
|
||||
assert result.success is False
|
||||
dependencies["repository"].stage_create.assert_not_awaited()
|
||||
dependencies["unit_of_work"].commit.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_commits_before_reporting_remote_count():
|
||||
"""共享工作流必须先本地提交,随后才更新远程复用次数。"""
|
||||
calls = []
|
||||
|
||||
async def commit():
|
||||
calls.append("commit")
|
||||
|
||||
async def report(_share_id):
|
||||
calls.append("report")
|
||||
|
||||
command, dependencies = _definition_command(report_fork=report)
|
||||
dependencies["unit_of_work"].commit.side_effect = commit
|
||||
|
||||
result = await command.fork(
|
||||
{
|
||||
"name": "Forked",
|
||||
"actions": "[]",
|
||||
"flows": "[]",
|
||||
"context": "{}",
|
||||
"event_conditions": "{}",
|
||||
},
|
||||
share_id=9,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert calls == ["commit", "report"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fork_invalid_json_stops_before_database_write():
|
||||
"""共享内容 JSON 无效时不得创建半成品工作流。"""
|
||||
command, dependencies = _definition_command()
|
||||
|
||||
result = await command.fork({"name": "Forked", "actions": "{"})
|
||||
|
||||
assert result.success is False
|
||||
assert result.message == "actions字段JSON格式错误"
|
||||
dependencies["repository"].stage_create.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_commit_failure_does_not_stop_runtime_or_delete_cache():
|
||||
"""重置提交失败时只回滚数据库,不影响现有运行态。"""
|
||||
command, dependencies = _definition_command(
|
||||
existing=_workflow(),
|
||||
commit_error=RuntimeError("commit failed"),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="commit failed"):
|
||||
await command.reset(7)
|
||||
|
||||
dependencies["unit_of_work"].rollback.assert_awaited_once_with()
|
||||
dependencies["stop_running"].assert_not_called()
|
||||
dependencies["delete_cache"].assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_commits_before_runtime_cleanup():
|
||||
"""工作流重置成功后再停止执行并删除缓存。"""
|
||||
calls = []
|
||||
command, dependencies = _definition_command(existing=_workflow())
|
||||
dependencies["unit_of_work"].commit.side_effect = lambda: calls.append("commit")
|
||||
dependencies["stop_running"].side_effect = lambda _id: calls.append("stop")
|
||||
dependencies["delete_cache"].side_effect = lambda _id: calls.append("cache")
|
||||
|
||||
result = await command.reset(7)
|
||||
|
||||
assert result.success is True
|
||||
assert calls == ["commit", "stop", "cache"]
|
||||
Reference in New Issue
Block a user