mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-04 23:17:20 +08:00
Merge remote-tracking branch 'origin/v3' into pr-6401-resolve
# Conflicts: # app/startup/modules_initializer.py # tests/test_configuration_ports.py
This commit is contained in:
@@ -20,6 +20,7 @@ from app.application.configuration import (
|
||||
get_transfer_retry_config,
|
||||
)
|
||||
from app.application.security.userconfig import UserConfigurationService
|
||||
from app.runtime.settings import RuntimeSettingsCompat, configure_runtime_settings_compat
|
||||
|
||||
|
||||
class _InlineDatabaseExecutor:
|
||||
@@ -71,6 +72,16 @@ def test_runtime_settings_service_hides_mutable_settings_implementation() -> Non
|
||||
assert service.get("VALUE") == "final"
|
||||
|
||||
|
||||
def test_runtime_settings_compat_delegates_to_concrete_service_backend() -> None:
|
||||
"""兼容 Settings 代理委托到真实设置对象时不会在 model_dump 中递归。"""
|
||||
service = RuntimeSettingsService(_MutableSettings())
|
||||
configure_runtime_settings_compat(service)
|
||||
|
||||
assert RuntimeSettingsCompat().model_dump(include={"VALUE"}) == {
|
||||
"VALUE": "before"
|
||||
}
|
||||
|
||||
|
||||
def test_system_config_service_supports_separate_reader_and_writer() -> None:
|
||||
"""应用服务可以分别注入只读与写入适配器。"""
|
||||
reader = MagicMock()
|
||||
|
||||
@@ -921,6 +921,9 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -
|
||||
(live_app / "app" / "application" / "site").mkdir(parents=True)
|
||||
live_public.mkdir()
|
||||
(live_app / "app" / "old.py").write_text("old", encoding="utf-8")
|
||||
(live_app / "app" / "plugins" / "__init__.py").write_text(
|
||||
"# legacy plugin compatibility entrypoint\n", encoding="utf-8"
|
||||
)
|
||||
(live_app / "app" / "plugins" / "plugin.py").write_text("plugin", encoding="utf-8")
|
||||
(live_app / "app" / "application" / "site" / "user.sites.v3.bin").write_text(
|
||||
"sites", encoding="utf-8"
|
||||
@@ -931,6 +934,9 @@ def test_failed_dependency_sync_does_not_replace_program_files(tmp_path: Path) -
|
||||
|
||||
update_tree = tmp_path / "update" / "App"
|
||||
(update_tree / "app" / "plugins").mkdir(parents=True)
|
||||
(update_tree / "app" / "plugins" / "__init__.py").write_text(
|
||||
"# legacy plugin compatibility entrypoint\n", encoding="utf-8"
|
||||
)
|
||||
(update_tree / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
|
||||
(update_tree / "uv.lock").write_text("version = 1\n", encoding="utf-8")
|
||||
(update_tree / "version.py").write_text("FRONTEND_VERSION = 'v3.0.1'\n", encoding="utf-8")
|
||||
|
||||
@@ -77,6 +77,15 @@ def test_dockerfile_assigns_each_payload_to_an_independent_stage() -> None:
|
||||
assert "RUN rm -rf /app/frontend-dist" in dockerfile
|
||||
|
||||
|
||||
def test_plugin_runtime_updates_preserve_legacy_base_entrypoint() -> None:
|
||||
"""更新和性能覆盖镜像必须保留旧插件导入 _PluginBase 所需的兼容入口。"""
|
||||
update_script = _read(ROOT / "docker" / "update.sh")
|
||||
perf_script = _read(ROOT / "scripts" / "perf" / "moviepilot_docker_ab.py")
|
||||
|
||||
assert 'rm -f "${stage_plugin_dir}/__init__.py"' not in update_script
|
||||
assert "rm -f /frozen/plugins/__init__.py" not in perf_script
|
||||
|
||||
|
||||
def test_release_workflows_pin_and_record_external_payload_identities() -> None:
|
||||
"""正式与 Beta 构建都必须以真实制品身份驱动缓存并写入镜像标签。"""
|
||||
for workflow_path in (RELEASE_WORKFLOW, BETA_WORKFLOW):
|
||||
|
||||
@@ -55,6 +55,12 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
||||
chain.save_cache = lambda _cache, _filename: None
|
||||
chain.remove_cache = lambda _filename: None
|
||||
chain.get_search_page_size = IndexerModule.get_search_page_size
|
||||
chain.search_plugin_torrents = lambda **_kwargs: []
|
||||
|
||||
async def no_plugin_results(**_kwargs):
|
||||
return []
|
||||
|
||||
chain.async_search_plugin_torrents = no_plugin_results
|
||||
return chain
|
||||
|
||||
async def test_start_recommend_task_restores_original_indices(self):
|
||||
@@ -185,7 +191,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
chain.search_torrents = search_torrents
|
||||
chain.search_site_torrents = search_torrents
|
||||
|
||||
with (
|
||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True),
|
||||
@@ -231,7 +237,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
chain.search_torrents = search_torrents
|
||||
chain.search_site_torrents = search_torrents
|
||||
|
||||
with (
|
||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
||||
@@ -277,7 +283,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
chain.search_torrents = search_torrents
|
||||
chain.search_site_torrents = search_torrents
|
||||
|
||||
with (
|
||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
||||
@@ -342,7 +348,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
chain.async_search_torrents = async_search_torrents
|
||||
chain.async_search_site_torrents = async_search_torrents
|
||||
|
||||
with (
|
||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 4, create=True),
|
||||
@@ -388,7 +394,7 @@ class SearchChainAIRecommendTest(unittest.IsolatedAsyncioTestCase):
|
||||
for index in range(count)
|
||||
]
|
||||
|
||||
chain.async_search_torrents = async_search_torrents
|
||||
chain.async_search_site_torrents = async_search_torrents
|
||||
|
||||
with (
|
||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 3, create=True),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.chain.search import SearchChain
|
||||
from app.modules.indexer import IndexerModule
|
||||
from app.runtime.config import settings
|
||||
|
||||
|
||||
def make_chain() -> SearchChain:
|
||||
"""构造不触发完整启动流程的搜索链。"""
|
||||
chain = object.__new__(SearchChain)
|
||||
chain.get_search_page_size = IndexerModule.get_search_page_size
|
||||
return chain
|
||||
|
||||
|
||||
def test_search_returns_plugin_results_without_indexer_sites():
|
||||
"""未配置 PT 站点时,插件资源仍应进入原生资源搜索。"""
|
||||
chain = make_chain()
|
||||
plugin_item = SimpleNamespace(title="Plugin Result", description="")
|
||||
calls = []
|
||||
chain.search_plugin_torrents = lambda **kwargs: calls.append(kwargs) or [plugin_item]
|
||||
|
||||
with (
|
||||
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||
):
|
||||
system_config_oper.return_value.get.return_value = []
|
||||
sites_helper.return_value.get_indexers.return_value = []
|
||||
results = chain._SearchChain__search_all_sites(keyword="keyword")
|
||||
|
||||
assert results == [plugin_item]
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["keyword"] == "keyword"
|
||||
|
||||
|
||||
def test_search_invokes_plugin_once_with_multiple_indexers():
|
||||
"""多个 PT 站点不应导致插件资源源被重复搜索。"""
|
||||
chain = make_chain()
|
||||
plugin_calls = []
|
||||
site_calls = []
|
||||
chain.search_plugin_torrents = lambda **kwargs: plugin_calls.append(kwargs) or [
|
||||
SimpleNamespace(title="Plugin Result", description="")
|
||||
]
|
||||
chain.search_site_torrents = lambda **kwargs: site_calls.append(kwargs) or [
|
||||
SimpleNamespace(title=f"Site {kwargs['site']['id']}", description="")
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(settings, "SEARCH_RESOURCE_PAGES", 1, create=True),
|
||||
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||
patch("app.chain.search.ProgressHelper") as progress_helper,
|
||||
):
|
||||
system_config_oper.return_value.get.return_value = [1, 2]
|
||||
sites_helper.return_value.get_indexers.return_value = [
|
||||
{"id": 1, "name": "站点一"},
|
||||
{"id": 2, "name": "站点二"},
|
||||
]
|
||||
progress_helper.return_value = SimpleNamespace(
|
||||
start=lambda: None, update=lambda **_kwargs: None, end=lambda: None
|
||||
)
|
||||
results = chain._SearchChain__search_all_sites(keyword="keyword")
|
||||
|
||||
assert len(plugin_calls) == 1
|
||||
assert sorted(call["site"]["id"] for call in site_calls) == [1, 2]
|
||||
assert len(results) == 3
|
||||
|
||||
|
||||
def test_async_search_returns_plugin_results_without_indexers():
|
||||
"""异步搜索应支持只有插件资源源的部署方式。"""
|
||||
chain = make_chain()
|
||||
plugin_item = SimpleNamespace(title="Plugin Result", description="")
|
||||
calls = []
|
||||
|
||||
async def plugin_search(**kwargs):
|
||||
calls.append(kwargs)
|
||||
return [plugin_item]
|
||||
|
||||
chain.async_search_plugin_torrents = plugin_search
|
||||
async def run_search():
|
||||
with (
|
||||
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||
):
|
||||
system_config_oper.return_value.get.return_value = []
|
||||
sites_helper.return_value.async_get_indexers = AsyncMock(return_value=[])
|
||||
return await chain._SearchChain__async_search_all_sites(keyword="keyword")
|
||||
|
||||
results = asyncio.run(run_search())
|
||||
|
||||
assert results == [plugin_item]
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_async_search_stream_emits_plugin_results_once_without_indexers():
|
||||
"""流式搜索完成事件不应重复发送插件资源。"""
|
||||
chain = make_chain()
|
||||
plugin_item = SimpleNamespace(title="Plugin Result", description="")
|
||||
|
||||
async def plugin_search(**_kwargs):
|
||||
return [plugin_item]
|
||||
|
||||
chain.async_search_plugin_torrents = plugin_search
|
||||
async def collect_events():
|
||||
with (
|
||||
patch("app.chain.search.get_configured_system_config") as system_config_oper,
|
||||
patch("app.chain.search.SitesHelper") as sites_helper,
|
||||
):
|
||||
system_config_oper.return_value.get.return_value = []
|
||||
sites_helper.return_value.async_get_indexers = AsyncMock(return_value=[])
|
||||
return [
|
||||
event
|
||||
async for event in chain._SearchChain__async_search_all_sites_stream(
|
||||
keyword="keyword"
|
||||
)
|
||||
]
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
|
||||
assert [event["type"] for event in events] == ["append", "done"]
|
||||
assert events[0]["items"] == [plugin_item]
|
||||
assert events[1]["items"] == []
|
||||
assert events[1]["total_items"] == 1
|
||||
Reference in New Issue
Block a user