Files
MoviePilot/tests/test_cli_auto_update.py

342 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()