Files
MoviePilot/tests/test_plugin_candidate_inventory.py
T

330 lines
12 KiB
Python

"""插件市场候选库存读取测试。"""
import asyncio
import pytest
from app.application.plugin.identity import TrustedPluginSourceType
from app.application.plugin.inventory import PluginCandidateInventoryReader
from app.application.plugin.source import LocalCandidateReadStatus, MarketReadStatus
OFFICIAL_MARKET = "https://github.com/jxxghp/MoviePilot-Plugins"
THIRD_PARTY_MARKET = "https://github.com/example/moviepilot-plugins"
def test_load_reads_each_market_in_v3_v2_v1_order_and_keeps_all_facts() -> None:
"""每个市场的三代索引都应有独立读取记录,且同 ID 候选不能被合并。"""
calls: list[tuple[str, str | None, bool]] = []
def loader(market: str, package_version: str | None, force: bool):
calls.append((market, package_version, force))
return {
"DemoPlugin": {
"version": f"{package_version or '1'}.0.0",
"v3": True,
},
}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[OFFICIAL_MARKET, THIRD_PARTY_MARKET],
force=True,
)
assert calls == [
(OFFICIAL_MARKET, "v3", True),
(OFFICIAL_MARKET, "v2", True),
(OFFICIAL_MARKET, None, True),
(THIRD_PARTY_MARKET, "v3", True),
(THIRD_PARTY_MARKET, "v2", True),
(THIRD_PARTY_MARKET, None, True),
]
assert inventory.complete
assert [(read.market, read.package_generation) for read in inventory.market_reads] == [
(OFFICIAL_MARKET, "v3"),
(OFFICIAL_MARKET, "v2"),
(OFFICIAL_MARKET, "v1"),
(THIRD_PARTY_MARKET, "v3"),
(THIRD_PARTY_MARKET, "v2"),
(THIRD_PARTY_MARKET, "v1"),
]
assert len(inventory.candidates_for("demoplugin")) == 6
def test_only_v3_compatible_entries_are_candidates() -> None:
"""V3 明确排除项以及 V1 未声明兼容项不能进入候选库存。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v3":
return {
"V3Plugin": {"version": "3.0.0"},
"ExcludedPlugin": {"version": "3.0.0", "v3": False},
}
if package_version == "v2":
return {
"SharedPlugin": {"version": "2.0.0"},
"ExcludedPlugin": {"version": "2.0.0", "v3": False},
}
return {
"DeclaredV3": {"version": "1.0.0", "v3": True},
"DeclaredV2": {"version": "1.0.0", "v2": True},
"Undeclared": {"version": "1.0.0"},
"ExcludedPlugin": {"version": "1.0.0", "v3": False, "v2": True},
}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
assert {
candidate.plugin_id
for candidate in inventory.online_candidates
} == {"V3Plugin", "SharedPlugin", "DeclaredV3", "DeclaredV2"}
assert not inventory.candidates_for("ExcludedPlugin")
assert not inventory.candidates_for("Undeclared")
def test_official_source_is_classified_and_public_candidate_uses_plugin_version() -> None:
"""官方仓库使用官方来源类型,候选公共字段与 Plugin schema 对齐。"""
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {"DemoPlugin": {"version": "3.1.0"}},
)
candidate = reader.load([OFFICIAL_MARKET]).online_candidates[0]
assert candidate.source_key == "github:jxxghp/moviepilot-plugins"
assert candidate.source_type is TrustedPluginSourceType.OFFICIAL
assert candidate.plugin_version == "3.1.0"
assert candidate.public_dict() == {
"plugin_id": "DemoPlugin",
"source_key": "github:jxxghp/moviepilot-plugins",
"source_type": "official",
"repo_url": "https://github.com/jxxghp/MoviePilot-Plugins",
"package_generation": "v3",
"plugin_version": "3.1.0",
}
def test_partial_generation_failure_blocks_tofu_but_keeps_successful_candidates() -> None:
"""某一代读取失败时保留其他代候选,但库存不能用于第三方 TOFU。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v2":
raise TimeoutError("timeout")
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
assert len(inventory.candidates_for("DemoPlugin")) == 2
assert not inventory.complete
assert not inventory.can_use_for_tofu
assert inventory.read_for(THIRD_PARTY_MARKET, "v2") is not None
assert inventory.read_for(THIRD_PARTY_MARKET, "v2").error
def test_absent_generation_is_complete_without_creating_candidates() -> None:
"""确定不存在的代际索引属于完整库存,不应被误判为网络失败。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v2":
return None
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
absent = inventory.read_for(THIRD_PARTY_MARKET, "v2")
assert absent is not None
assert absent.status is MarketReadStatus.ABSENT
assert absent.candidates == ()
assert inventory.complete
assert inventory.can_use_for_tofu
assert len(inventory.candidates_for("DemoPlugin")) == 2
def test_empty_index_is_present_and_complete() -> None:
"""真实存在但为空的索引与 absent 保持可观察差异。"""
inventory = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
).load([THIRD_PARTY_MARKET])
assert inventory.complete
assert all(
read.status is MarketReadStatus.PRESENT
for read in inventory.market_reads
)
assert inventory.online_candidates == ()
def test_loader_exception_blocks_tofu() -> None:
"""Adapter 读取失败时必须阻止唯一第三方来源 TOFU。"""
def loader(_market: str, package_version: str | None, _force: bool):
if package_version == "v2":
raise TimeoutError("timeout")
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
inventory = PluginCandidateInventoryReader(market_loader=loader).load(
[THIRD_PARTY_MARKET]
)
assert not inventory.complete
assert not inventory.can_use_for_tofu
assert inventory.read_for(THIRD_PARTY_MARKET, "v2").status is MarketReadStatus.FAILED
def test_local_scan_preserves_absent_present_and_failed_states() -> None:
"""本地仓库扫描不能把未配置、空扫描和异常读取混为一谈。"""
def market_loader(*_args):
return {}
absent = PluginCandidateInventoryReader(market_loader=market_loader).load(
[THIRD_PARTY_MARKET]
)
present = PluginCandidateInventoryReader(
market_loader=market_loader,
local_candidate_loader=lambda: {},
).load([THIRD_PARTY_MARKET])
def failed_loader():
raise OSError("local repository unavailable")
failed = PluginCandidateInventoryReader(
market_loader=market_loader,
local_candidate_loader=failed_loader,
).load([THIRD_PARTY_MARKET])
assert absent.local_read.status is LocalCandidateReadStatus.ABSENT
assert present.local_read.status is LocalCandidateReadStatus.PRESENT
assert present.local_read.candidates == ()
assert failed.local_read.status is LocalCandidateReadStatus.FAILED
assert failed.local_read.error == "local repository unavailable"
def test_local_candidates_never_expose_path_in_inventory_projection() -> None:
"""本地候选可参与库存,但公共投影永不携带本地仓库路径。"""
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
local_candidate_loader=lambda: {
"LocalPlugin": {
"version": "3.0.0",
"package_version": "v3",
"repo_url": "local://LocalPlugin?path=/private/local&version=v3",
"path": "/private/local/plugins/LocalPlugin",
"repo_path": "/private/local",
},
},
)
inventory = reader.load([OFFICIAL_MARKET])
public = inventory.public_dict()
assert inventory.local_candidates[0].plugin_id == "LocalPlugin"
assert public["local_candidates"] == [{
"plugin_id": "LocalPlugin",
"source_type": "local",
"package_generation": "v3",
"plugin_version": "3.0.0",
}]
assert "/private/local" not in str(public)
def test_invalid_local_candidate_does_not_abort_online_inventory() -> None:
"""本地索引中的坏代际条目应被跳过,不能丢失在线库存。"""
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {
"OnlinePlugin": {"version": "3.0.0"},
},
local_candidate_loader=lambda: {
"BrokenLocal": {
"version": "1.0.0",
"package_version": "v9",
},
},
)
inventory = reader.load([OFFICIAL_MARKET])
assert [candidate.plugin_id for candidate in inventory.online_candidates] == [
"OnlinePlugin",
"OnlinePlugin",
]
assert inventory.local_candidates == ()
def test_invalid_market_is_recorded_for_each_generation_without_network_call() -> None:
"""非法市场配置应形成三条失败事实,且不会调用市场读取端口。"""
calls: list[object] = []
def read(*_args):
calls.append(True)
return {}
inventory = PluginCandidateInventoryReader(market_loader=read).load(
["https://example.com/not-github"]
)
assert calls == []
assert len(inventory.market_reads) == 3
assert all(not read.succeeded for read in inventory.market_reads)
assert not inventory.complete
@pytest.mark.asyncio
async def test_async_loader_preserves_generation_facts() -> None:
"""异步读取端口与同步端口拥有相同的市场代际快照合同。"""
calls: list[str | None] = []
async def loader(_market: str, package_version: str | None, _force: bool):
calls.append(package_version)
return {"DemoPlugin": {"version": "3.0.0", "v3": True}}
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
async_market_loader=loader,
)
inventory = await reader.async_load([THIRD_PARTY_MARKET])
assert calls == ["v3", "v2", None]
assert inventory.complete
assert [read.package_generation for read in inventory.market_reads] == [
"v3", "v2", "v1"
]
@pytest.mark.asyncio
async def test_async_inventory_bounds_large_market_working_set() -> None:
"""大市场库存允许 24 个并发读取,但不能无界放大出站连接。"""
active = 0
max_active = 0
first_batch_started = asyncio.Event()
release = asyncio.Event()
async def loader(_market: str, _package_version: str | None, _force: bool):
nonlocal active, max_active
active += 1
max_active = max(max_active, active)
if active == 24:
first_batch_started.set()
await release.wait()
active -= 1
return {}
markets = [
f"https://github.com/concurrency-owner/repository-{index}"
for index in range(10)
]
reader = PluginCandidateInventoryReader(
market_loader=lambda *_args: {},
async_market_loader=loader,
)
inventory_task = asyncio.create_task(reader.async_load(markets))
await asyncio.wait_for(first_batch_started.wait(), timeout=1)
assert active == 24
release.set()
inventory = await inventory_task
assert max_active == 24
assert inventory.complete