Files
MoviePilot/tests/test_plugin_catalog_service.py
T

196 lines
6.5 KiB
Python

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
@pytest.mark.asyncio
async def test_async_collect_cancels_all_loaders_when_parent_is_cancelled():
"""请求取消时必须取消并回收全部市场 loader,不能把子任务遗留在事件循环。"""
service = _service()
blocker = asyncio.Event()
all_started = asyncio.Event()
started = 0
cancelled = 0
async def loader(_market: str, _package_version: str | None, _force: bool):
"""记录市场 loader 的启动和取消,并等待测试释放。"""
nonlocal started, cancelled
started += 1
if started == 2:
all_started.set()
try:
await blocker.wait()
except asyncio.CancelledError:
cancelled += 1
raise
return []
collect_task = asyncio.create_task(
service.async_collect(
markets=["https://market-a", "https://market-b"],
compatible_flags=[],
force=True,
loader=loader,
)
)
await asyncio.wait_for(all_started.wait(), timeout=1)
collect_task.cancel()
try:
with pytest.raises(asyncio.CancelledError):
await collect_task
await asyncio.sleep(0)
assert cancelled == 2
finally:
blocker.set()
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_async_collect_cleans_loaders_when_progress_callback_fails():
"""进度回调异常也必须取消并回收尚未完成的市场 loader。"""
service = _service()
blocker = asyncio.Event()
slow_started = asyncio.Event()
slow_cancelled = asyncio.Event()
async def loader(market: str, _package_version: str | None, _force: bool):
"""让一个市场立即完成,另一个保持阻塞以验证异常清理。"""
if market == "https://market-a":
await slow_started.wait()
return []
slow_started.set()
try:
await blocker.wait()
except asyncio.CancelledError:
slow_cancelled.set()
raise
return []
def progress(*, value: float, **_kwargs) -> None:
"""首个市场完成时模拟进度消费者失败。"""
if value > 0:
raise RuntimeError("progress unavailable")
with pytest.raises(RuntimeError, match="progress unavailable"):
await asyncio.wait_for(
service.async_collect(
markets=["https://market-a", "https://market-b"],
compatible_flags=[],
force=True,
loader=loader,
progress_callback=progress,
),
timeout=1,
)
assert slow_cancelled.is_set()