mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
342 lines
13 KiB
Python
342 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import importlib.util
|
||
import json
|
||
import sys
|
||
import tempfile
|
||
import uuid
|
||
from pathlib import Path
|
||
from types import ModuleType, SimpleNamespace
|
||
from unittest.mock import patch
|
||
|
||
import pytest
|
||
|
||
MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
|
||
|
||
|
||
class _DummySystemHelper:
|
||
@staticmethod
|
||
def consume_one_shot_dev_update():
|
||
return False
|
||
|
||
|
||
def load_cli_module():
|
||
"""隔离加载 CLI,使用真实布尔配置形状验证启动更新决策。"""
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
root = Path(temp_dir)
|
||
settings = SimpleNamespace(
|
||
TEMP_PATH=root / "temp",
|
||
LOG_PATH=root / "logs",
|
||
ROOT_PATH=root,
|
||
FRONTEND_PATH=str(root / "public"),
|
||
CONFIG_PATH=root / "config",
|
||
PACKAGE_CACHE_PATH=root / "custom-package-cache",
|
||
HOST="127.0.0.1",
|
||
PORT=3001,
|
||
NGINX_PORT=3000,
|
||
PROXY_HOST="",
|
||
PIP_PROXY="",
|
||
GITHUB_TOKEN="",
|
||
MOVIEPILOT_AUTO_UPDATE=False,
|
||
MOVIEPILOT_UPDATE_DEV=False,
|
||
PROXY={},
|
||
REPO_GITHUB_HEADERS=lambda _repo: {},
|
||
)
|
||
|
||
app_module = ModuleType("app")
|
||
core_module = ModuleType("app.core")
|
||
helper_module = ModuleType("app.helper")
|
||
config_module = ModuleType("app.runtime.config")
|
||
system_module = ModuleType("app.runtime.state")
|
||
version_module = ModuleType("version")
|
||
psutil_module = ModuleType("psutil")
|
||
|
||
app_module.__path__ = []
|
||
core_module.__path__ = []
|
||
helper_module.__path__ = []
|
||
config_module.Settings = type("Settings", (), {})
|
||
config_module.settings = settings
|
||
system_module.SystemHelper = _DummySystemHelper
|
||
version_module.APP_VERSION = "v2.10.11"
|
||
psutil_module.STATUS_ZOMBIE = "zombie"
|
||
psutil_module.NoSuchProcess = RuntimeError
|
||
psutil_module.AccessDenied = RuntimeError
|
||
psutil_module.ZombieProcess = RuntimeError
|
||
psutil_module.Process = object
|
||
|
||
stub_modules = {
|
||
"app": app_module,
|
||
"app.core": core_module,
|
||
"app.helper": helper_module,
|
||
"app.runtime.config": config_module,
|
||
"app.runtime.state": system_module,
|
||
"version": version_module,
|
||
"psutil": psutil_module,
|
||
}
|
||
|
||
module_name = f"moviepilot_app_cli_{uuid.uuid4().hex}"
|
||
spec = importlib.util.spec_from_file_location(module_name, MODULE_PATH)
|
||
module = importlib.util.module_from_spec(spec)
|
||
assert spec and spec.loader
|
||
|
||
with patch.dict(sys.modules, stub_modules):
|
||
spec.loader.exec_module(module)
|
||
# CLI 生产代码只依赖读取端口;这个动态加载器仍提供旧字段 patch 点,
|
||
# 让历史更新流程测试可以独立于全局测试配置运行。
|
||
module.settings = settings
|
||
module.get_runtime_setting = lambda key, default=None: getattr(
|
||
settings, key, default
|
||
)
|
||
return module
|
||
|
||
|
||
def test_resolve_auto_update_targets_keeps_dev_branch_tracking():
|
||
module = load_cli_module()
|
||
with patch.object(module, "_git_current_branch", return_value="v3"):
|
||
assert module._resolve_auto_update_targets("dev") == "latest"
|
||
assert module._resolve_auto_update_targets("release") is None
|
||
|
||
|
||
def test_one_shot_dev_update_overrides_disabled_default():
|
||
"""一次性手动更新不受两个自动开关关闭的影响。"""
|
||
module = load_cli_module()
|
||
module.settings.MOVIEPILOT_AUTO_UPDATE = False
|
||
|
||
with patch.object(
|
||
module.SystemHelper, "consume_one_shot_dev_update", return_value=True
|
||
):
|
||
assert module._auto_update_mode() == "dev"
|
||
|
||
|
||
@pytest.mark.parametrize("auto_update", [True, False])
|
||
@pytest.mark.parametrize("update_dev", [True, False])
|
||
def test_dev_tracking_is_independent_of_automatic_checks(auto_update, update_dev):
|
||
"""检查开关不触发启动更新,Dev 开关单独选择开发分支。"""
|
||
module = load_cli_module()
|
||
module.settings.MOVIEPILOT_AUTO_UPDATE = auto_update
|
||
module.settings.MOVIEPILOT_UPDATE_DEV = update_dev
|
||
assert module._auto_update_mode() == ("dev" if update_dev else "false")
|
||
|
||
|
||
def test_release_mode_does_not_update_during_start():
|
||
module = load_cli_module()
|
||
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
|
||
module.subprocess, "run"
|
||
) as run_mock:
|
||
module._best_effort_auto_update()
|
||
run_mock.assert_not_called()
|
||
|
||
|
||
def test_prepared_release_uses_downloaded_package_before_dev_mode():
|
||
module = load_cli_module()
|
||
module.PREPARED_UPDATE_ROOT.mkdir(parents=True)
|
||
backend = module.PREPARED_UPDATE_ROOT / "backend.zip"
|
||
frontend = module.PREPARED_UPDATE_ROOT / "frontend.zip"
|
||
backend.write_bytes(b"backend")
|
||
frontend.write_bytes(b"frontend")
|
||
module.PREPARED_UPDATE_MANIFEST.write_text(
|
||
json.dumps(
|
||
{
|
||
"version": "v3.1.0",
|
||
"frontend_version": "v3.1.0",
|
||
"backend_archive": str(backend),
|
||
"frontend_archive": str(frontend),
|
||
"backend_sha256": hashlib.sha256(backend.read_bytes()).hexdigest(),
|
||
"frontend_sha256": hashlib.sha256(frontend.read_bytes()).hexdigest(),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
|
||
with patch.object(module, "_auto_update_mode", return_value="dev") as mode, patch.object(
|
||
module.subprocess, "run", return_value=run_result
|
||
) as run_mock, patch.object(module.click, "echo"):
|
||
module._best_effort_auto_update()
|
||
|
||
command = run_mock.call_args.args[0]
|
||
assert "--offline-backend" in command
|
||
assert command[command.index("--frontend-archive") + 1] == str(frontend)
|
||
assert not module.PREPARED_UPDATE_MANIFEST.exists()
|
||
mode.assert_not_called()
|
||
|
||
|
||
def test_prepared_release_passes_package_env_and_overrides_proxy():
|
||
module = load_cli_module()
|
||
module.settings.PROXY_HOST = "http://proxy.example:7890"
|
||
module.settings.PIP_PROXY = "https://mirror.example/simple"
|
||
module.PREPARED_UPDATE_ROOT.mkdir(parents=True, exist_ok=True)
|
||
backend = module.PREPARED_UPDATE_ROOT / "backend.zip"
|
||
frontend = module.PREPARED_UPDATE_ROOT / "frontend.zip"
|
||
backend.write_bytes(b"backend")
|
||
frontend.write_bytes(b"frontend")
|
||
module.PREPARED_UPDATE_MANIFEST.write_text(
|
||
json.dumps(
|
||
{
|
||
"version": "v3.1.0",
|
||
"frontend_version": "v3.1.0",
|
||
"backend_archive": str(backend),
|
||
"frontend_archive": str(frontend),
|
||
"backend_sha256": hashlib.sha256(backend.read_bytes()).hexdigest(),
|
||
"frontend_sha256": hashlib.sha256(frontend.read_bytes()).hexdigest(),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
|
||
with patch.dict(
|
||
module.os.environ,
|
||
{"HTTPS_PROXY": "http://old.example:8080"},
|
||
clear=True,
|
||
), patch.object(
|
||
module.subprocess, "run", return_value=run_result
|
||
) as run_mock, patch.object(module.click, "echo"):
|
||
assert module._apply_prepared_release_update() is True
|
||
|
||
env = run_mock.call_args.kwargs["env"]
|
||
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
|
||
assert env["PIP_PROXY"] == "https://mirror.example/simple"
|
||
assert env["PACKAGE_CACHE_ROOT"] == str(module.settings.PACKAGE_CACHE_PATH)
|
||
assert env["UV_CACHE_DIR"] == str(module.settings.PACKAGE_CACHE_PATH / "uv")
|
||
|
||
|
||
def test_best_effort_auto_update_does_not_pass_frontend_version_override():
|
||
module = load_cli_module()
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
|
||
with patch.object(module, "_auto_update_mode", return_value="dev"), patch.object(
|
||
module, "_resolve_auto_update_targets", return_value="latest"
|
||
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
|
||
module.click, "echo"
|
||
):
|
||
module._best_effort_auto_update()
|
||
|
||
command = run_mock.call_args.args[0]
|
||
assert command[1:5] == [
|
||
str(module._repo_root() / "scripts" / "local_setup.py"),
|
||
"update",
|
||
"all",
|
||
"--ref",
|
||
]
|
||
assert "--frontend-version" not in command
|
||
|
||
|
||
def test_best_effort_auto_update_passes_package_env_and_overrides_proxy():
|
||
module = load_cli_module()
|
||
module.settings.PROXY_HOST = "http://proxy.example:7890"
|
||
module.settings.PIP_PROXY = "https://mirror.example/simple"
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
|
||
with patch.dict(module.os.environ, {"HTTPS_PROXY": "http://old.example:8080"}, clear=True), patch.object(
|
||
module, "_auto_update_mode", return_value="dev"
|
||
), patch.object(module, "_resolve_auto_update_targets", return_value="latest"), patch.object(
|
||
module.subprocess, "run", return_value=run_result
|
||
) as run_mock, patch.object(module.click, "echo"):
|
||
module._best_effort_auto_update()
|
||
|
||
env = run_mock.call_args.kwargs["env"]
|
||
assert env["HTTPS_PROXY"] == "http://proxy.example:7890"
|
||
assert env["PIP_PROXY"] == "https://mirror.example/simple"
|
||
assert env["PACKAGE_CACHE_ROOT"] == str(module.settings.PACKAGE_CACHE_PATH)
|
||
assert env["UV_CACHE_DIR"] == str(module.settings.PACKAGE_CACHE_PATH / "uv")
|
||
|
||
|
||
def test_best_effort_auto_update_derives_tool_cache_from_existing_root():
|
||
module = load_cli_module()
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
package_cache_root = Path("/custom/package-cache-root")
|
||
|
||
with patch.dict(
|
||
module.os.environ,
|
||
{"PACKAGE_CACHE_ROOT": str(package_cache_root)},
|
||
clear=True,
|
||
), patch.object(module, "_auto_update_mode", return_value="dev"), patch.object(
|
||
module, "_resolve_auto_update_targets", return_value="latest"
|
||
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
|
||
module.click, "echo"
|
||
):
|
||
module._best_effort_auto_update()
|
||
|
||
env = run_mock.call_args.kwargs["env"]
|
||
assert env["PACKAGE_CACHE_ROOT"] == str(package_cache_root)
|
||
assert env["UV_CACHE_DIR"] == str(package_cache_root / "uv")
|
||
|
||
|
||
def _prepared_resource_files(module):
|
||
resource_dir = module.PREPARED_UPDATE_ROOT / "resources"
|
||
resource_dir.mkdir(parents=True, exist_ok=True)
|
||
resources = []
|
||
for name, content in (
|
||
("user.sites.v3.bin", b"index"),
|
||
("sites.cpython-test.so", b"auth"),
|
||
):
|
||
path = resource_dir / name
|
||
path.write_bytes(content)
|
||
resources.append(
|
||
{
|
||
"name": name,
|
||
"path": str(path),
|
||
"sha256": hashlib.sha256(content).hexdigest(),
|
||
}
|
||
)
|
||
return resources
|
||
|
||
|
||
def test_prepared_resource_update_uses_offline_resource_install_only():
|
||
module = load_cli_module()
|
||
module.PREPARED_UPDATE_ROOT.mkdir(parents=True, exist_ok=True)
|
||
module.PREPARED_UPDATE_MANIFEST.write_text(
|
||
json.dumps({"targets": ["resources"], "resource_files": _prepared_resource_files(module)}),
|
||
encoding="utf-8",
|
||
)
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
|
||
with patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
|
||
module.click, "echo"
|
||
):
|
||
assert module._apply_prepared_release_update() is True
|
||
|
||
assert len(run_mock.call_args_list) == 1
|
||
command = run_mock.call_args.args[0]
|
||
assert command[1:4] == [str(module._repo_root() / "scripts" / "local_setup.py"), "install-resources", "--resource-dir"]
|
||
assert "update" not in command
|
||
assert not module.PREPARED_UPDATE_MANIFEST.exists()
|
||
|
||
|
||
def test_prepared_application_and_resources_install_in_order():
|
||
module = load_cli_module()
|
||
module.PREPARED_UPDATE_ROOT.mkdir(parents=True, exist_ok=True)
|
||
backend = module.PREPARED_UPDATE_ROOT / "backend.zip"
|
||
frontend = module.PREPARED_UPDATE_ROOT / "frontend.zip"
|
||
backend.write_bytes(b"backend")
|
||
frontend.write_bytes(b"frontend")
|
||
module.PREPARED_UPDATE_MANIFEST.write_text(
|
||
json.dumps(
|
||
{
|
||
"targets": ["application", "resources"],
|
||
"version": "v3.1.0",
|
||
"frontend_version": "v3.1.0",
|
||
"backend_archive": str(backend),
|
||
"frontend_archive": str(frontend),
|
||
"backend_sha256": hashlib.sha256(backend.read_bytes()).hexdigest(),
|
||
"frontend_sha256": hashlib.sha256(frontend.read_bytes()).hexdigest(),
|
||
"resource_files": _prepared_resource_files(module),
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||
|
||
with patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
|
||
module.click, "echo"
|
||
):
|
||
assert module._apply_prepared_release_update() is True
|
||
|
||
commands = [call.args[0] for call in run_mock.call_args_list]
|
||
assert len(commands) == 2
|
||
assert "update" in commands[0]
|
||
assert "install-resources" in commands[1]
|
||
assert not module.PREPARED_UPDATE_MANIFEST.exists()
|