Files
MoviePilot/tests/test_system_update_manager.py

628 lines
24 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.

"""系统后台更新状态机测试。"""
import errno
import json
import threading
import zipfile
from contextlib import nullcontext
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, call
import pytest
from app.adapters.system import update as update_module
def _manager(monkeypatch, tmp_path: Path):
"""创建使用临时状态目录的更新管理器。"""
monkeypatch.setattr(
update_module,
"get_runtime_setting",
lambda key: tmp_path if key == "TEMP_PATH" else None,
)
manager = object.__new__(update_module.SystemUpdateManager)
manager._lock = threading.RLock()
manager._download_active = False
manager._active_target = None
return manager
def _docker_manager(monkeypatch, tmp_path: Path):
"""创建指向隔离 Docker 目录的更新管理器。"""
runtime_settings = {
"TEMP_PATH": tmp_path / "config" / "temp",
"ROOT_PATH": tmp_path / "app",
"FRONTEND_PATH": tmp_path / "public",
"VENV_PATH": tmp_path / "venv",
"UV_BIN": tmp_path / "uv",
"MOVIEPILOT_AUTO_UPDATE": False,
"AUTO_UPDATE_RESOURCE": True,
"PIP_PROXY": "",
"PROXY_HOST": "",
}
monkeypatch.setattr(
update_module,
"get_runtime_setting",
lambda key: runtime_settings[key],
)
monkeypatch.setattr(update_module, "is_docker", lambda: True)
manager = object.__new__(update_module.SystemUpdateManager)
manager._lock = threading.RLock()
manager._download_active = False
manager._active_target = None
return manager
def _response(payload, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: payload)
def test_status_reads_live_auto_update_setting_without_discarding_cached_update(monkeypatch, tmp_path):
"""切换提醒设置立即反映到状态,缓存版本仍供手动升级使用。"""
manager = _manager(monkeypatch, tmp_path)
manager._write_state(state="available", version="v3.1.0", can_update=True)
for enabled in (True, False, True):
monkeypatch.setattr(
update_module, "get_runtime_setting",
lambda key: tmp_path if key == "TEMP_PATH" else enabled,
)
status = manager.get_status()
assert status.auto_update is enabled
assert status.state == "available"
assert status.version == "v3.1.0"
assert status.can_update is True
@pytest.mark.parametrize("auto_update", [False, True])
@pytest.mark.parametrize("auto_update_resource", [False, True])
def test_scheduled_check_respects_independent_switches(
monkeypatch, tmp_path, auto_update, auto_update_resource
):
"""自动检查只访问已开启的目标;手动检查仍可访问两类更新。"""
manager = _manager(monkeypatch, tmp_path)
values = {
"TEMP_PATH": tmp_path,
"MOVIEPILOT_AUTO_UPDATE": auto_update,
"AUTO_UPDATE_RESOURCE": auto_update_resource,
}
monkeypatch.setattr(update_module, "get_runtime_setting", values.get)
checked = []
monkeypatch.setattr(manager, "_check_application", lambda: checked.append("application"))
monkeypatch.setattr(manager, "_check_resources", lambda: checked.append("resources"))
status = manager.check_scheduled()
assert checked == [
target for target, enabled in (("application", auto_update), ("resources", auto_update_resource))
if enabled
]
assert status.auto_update is auto_update
assert status.auto_update_resource is auto_update_resource
checked.clear()
manager.check()
assert checked == ["application", "resources"]
def test_check_exposes_new_stable_release(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
logs = []
releases = [
{"tag_name": "v3.2.0-beta", "prerelease": True, "draft": False},
{
"tag_name": "v3.1.0",
"name": "MoviePilot v3.1.0",
"body": "changes",
"published_at": "2026-08-24T00:00:00Z",
"prerelease": False,
"draft": False,
},
]
monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=lambda _url: _response(releases)))
monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.0")
monkeypatch.setattr(update_module.logger, "info", logs.append)
status = manager.check("application")
assert status.state == "available"
assert status.version == "v3.1.0"
assert status.release_notes == "changes"
assert status.can_update is True
assert logs == ["发现 MoviePilot 主程序更新v3.0.0 -> v3.1.0"]
def test_check_logs_when_application_is_current(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
logs = []
releases = [
{
"tag_name": "v3.0.0",
"name": "MoviePilot v3.0.0",
"prerelease": False,
"draft": False,
}
]
monkeypatch.setattr(manager, "_request", lambda: SimpleNamespace(get_res=lambda _url: _response(releases)))
monkeypatch.setattr(update_module, "get_app_version", lambda: "v3.0.0")
monkeypatch.setattr(update_module.logger, "info", logs.append)
status = manager.check("application")
assert status.state == "idle"
assert status.can_update is False
assert logs == ["MoviePilot 主程序已是最新版本v3.0.0"]
def test_public_download_request_omits_github_authorization(monkeypatch, tmp_path):
"""公开归档下载不得复用 GitHub API 的认证请求头。"""
manager = _manager(monkeypatch, tmp_path)
settings = {
"TEMP_PATH": tmp_path,
"PROXY": {"https": "http://proxy.example:7890"},
"GITHUB_HEADERS": {"Authorization": "Bearer github-token"},
}
monkeypatch.setattr(update_module, "get_runtime_setting", settings.__getitem__)
request_utils = Mock()
request_utils.return_value.get_stream.return_value = nullcontext(
SimpleNamespace(
status_code=200,
headers={"content-length": "7"},
iter_content=lambda **_kwargs: [b"archive"],
)
)
monkeypatch.setattr(update_module, "RequestUtils", request_utils)
manager._request()
destination = tmp_path / "backend.zip"
downloaded, content_length = manager._download_file(
"https://github.com/jxxghp/MoviePilot/archive/refs/tags/v3.0.2.zip",
destination,
0,
0,
)
assert request_utils.call_args_list == [
call(
proxies=settings["PROXY"],
headers=settings["GITHUB_HEADERS"],
timeout=60,
),
call(proxies=settings["PROXY"], timeout=60),
]
assert downloaded == content_length == 7
assert destination.read_bytes() == b"archive"
def test_scheduled_check_failure_stays_silent(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
manager,
"_request",
lambda: SimpleNamespace(get_res=lambda _url: _response({}, status_code=503)),
)
status = manager.check("application")
assert status.state == "idle"
assert status.error
def test_interrupted_download_becomes_retryable_failure(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
manager._write_state(state="downloading", version="v3.1.0")
status = manager.get_status()
assert status.state == "failed"
assert status.can_update is True
assert "中断" in status.error
def test_ready_resource_update_clears_after_loaded_version_reaches_target(
monkeypatch,
tmp_path,
):
"""运行资源已达到目标版本时应清除残留提示,并保留另一类待安装制品。"""
versions = ["3.0.1", "3.0.9"]
monkeypatch.setattr(
update_module,
"get_resource_versions",
lambda: tuple(versions),
)
manager = _manager(monkeypatch, tmp_path)
manager._merge_prepared_manifest(
{
"version": "v3.1.0",
"backend_archive": "/tmp/backend.zip",
"resource_package_version": "10",
"resource_files": [{"name": "user.sites.v3.bin"}],
}
)
manager._write_item(
"resources",
state="ready",
version="10",
current_auth_version="3.0.1",
current_indexer_version="3.0.9",
indexer_version="3.0.10",
can_update=False,
can_install=True,
)
pending = manager.get_status()
pending_resource = next(item for item in pending.updates if item.type == "resources")
assert pending_resource.state == "ready"
versions[1] = "3.0.10"
current = manager.get_status()
current_resource = next(item for item in current.updates if item.type == "resources")
prepared = json.loads((manager._root / "prepared.json").read_text(encoding="utf-8"))
assert current_resource.state == "idle"
assert current_resource.current_indexer_version == "3.0.10"
assert current_resource.indexer_version is None
assert current_resource.version is None
assert current_resource.can_install is False
assert prepared["version"] == "v3.1.0"
assert prepared["backend_archive"] == "/tmp/backend.zip"
assert "resource_package_version" not in prepared
assert "resource_files" not in prepared
def test_download_prepares_matching_backend_and_frontend_archives(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
backend_fixture = tmp_path / "source-backend.zip"
frontend_fixture = tmp_path / "source-frontend.zip"
with zipfile.ZipFile(backend_fixture, "w") as archive:
archive.writestr(
"MoviePilot-v3.1.0/version.py",
"APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n",
)
archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n")
archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n")
with zipfile.ZipFile(frontend_fixture, "w") as archive:
archive.writestr("dist/index.html", "ok")
archive.writestr("dist/version.txt", "v3.1.0\n")
fixtures = iter((backend_fixture, frontend_fixture))
def download(_url, destination, downloaded_before, _total_hint):
source = next(fixtures)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
size = destination.stat().st_size
return downloaded_before + size, size
monkeypatch.setattr(manager, "_download_file", download)
monkeypatch.setattr(update_module, "is_docker", lambda: True)
monkeypatch.setattr(
manager,
"_fetch_frontend_release",
lambda _version: {
"assets": [
{
"name": "dist.zip",
"size": frontend_fixture.stat().st_size,
"browser_download_url": "https://example.invalid/dist.zip",
"digest": f"sha256:{manager._sha256(frontend_fixture)}",
}
]
},
)
manager._write_state(state="downloading", version="v3.1.0")
manager._active_target = "application"
manager._download_update("application")
status = manager.get_status()
prepared = json.loads((manager._root / "prepared.json").read_text(encoding="utf-8"))
assert status.state == "ready"
assert status.progress == 100
assert status.frontend_version == "v3.1.0"
assert prepared["backend_sha256"] == manager._sha256(manager._backend_archive)
assert prepared["frontend_sha256"] == manager._sha256(manager._frontend_archive)
def test_application_download_keeps_prepared_resource_package(monkeypatch, tmp_path):
"""应用包重新下载时不得删除另一类已经准备好的资源包。"""
manager = _manager(monkeypatch, tmp_path)
resource_dir = manager._resource_dir
resource_dir.mkdir(parents=True)
resource_file = resource_dir / "user.sites.v3.bin"
resource_file.write_bytes(b"resource")
manager._merge_prepared_manifest(
{
"resource_package_version": "10",
"resource_files": [
{
"name": resource_file.name,
"type": "indexer",
"version": "3.0.8",
"path": str(resource_file),
"sha256": manager._sha256(resource_file),
}
],
}
)
backend_fixture = tmp_path / "source-backend.zip"
frontend_fixture = tmp_path / "source-frontend.zip"
with zipfile.ZipFile(backend_fixture, "w") as archive:
archive.writestr(
"MoviePilot-v3.1.0/version.py",
"APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n",
)
archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n")
archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n")
with zipfile.ZipFile(frontend_fixture, "w") as archive:
archive.writestr("dist/index.html", "ok")
archive.writestr("dist/version.txt", "v3.1.0\n")
fixtures = iter((backend_fixture, frontend_fixture))
def download(_url, destination, downloaded_before, _total_hint):
source = next(fixtures)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(source.read_bytes())
size = destination.stat().st_size
return downloaded_before + size, size
monkeypatch.setattr(manager, "_download_file", download)
monkeypatch.setattr(update_module, "is_docker", lambda: True)
monkeypatch.setattr(
manager,
"_fetch_frontend_release",
lambda _version: {
"assets": [
{
"name": "dist.zip",
"size": frontend_fixture.stat().st_size,
"browser_download_url": "https://example.invalid/dist.zip",
}
]
},
)
manager._write_state(state="downloading", version="v3.1.0")
manager._active_target = "application"
manager._download_update("application")
prepared = json.loads((manager._root / "prepared.json").read_text(encoding="utf-8"))
assert prepared["resource_package_version"] == "10"
assert prepared["resource_files"][0]["name"] == "user.sites.v3.bin"
def test_resource_manifest_requires_complete_current_platform_package(monkeypatch, tmp_path):
"""资源安装只能接受同时包含索引和当前平台认证文件的完整包。"""
manager = _manager(monkeypatch, tmp_path)
monkeypatch.setattr(
update_module.ResourceHelper,
"_get_needed_files",
classmethod(lambda cls: ["user.sites.v3.bin", "sites.cpython-test.so"]),
)
resource_dir = manager._resource_dir
resource_dir.mkdir(parents=True)
resource_file = resource_dir / "user.sites.v3.bin"
resource_file.write_bytes(b"resource")
prepared = {
"resource_files": [
{
"name": resource_file.name,
"path": str(resource_file),
"sha256": manager._sha256(resource_file),
}
]
}
with pytest.raises(RuntimeError, match="完整资源包"):
manager._validate_resource_manifest(prepared)
def test_request_install_rejects_modified_prepared_package(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
manager._root.mkdir(parents=True)
manager._backend_archive.write_bytes(b"backend")
manager._frontend_archive.write_bytes(b"frontend")
(manager._root / "prepared.json").write_text(
json.dumps(
{
"version": "v3.1.0",
"frontend_version": "v3.1.0",
"backend_archive": str(manager._backend_archive),
"frontend_archive": str(manager._frontend_archive),
"backend_sha256": "invalid",
"frontend_sha256": manager._sha256(manager._frontend_archive),
}
),
encoding="utf-8",
)
manager._write_state(state="ready", version="v3.1.0", can_install=True)
success, message = manager.request_install()
assert success is False
assert "后端更新包校验失败" in message
assert not manager._install_file.exists()
assert manager.get_status().state == "failed"
def test_cancel_install_returns_prepared_update_to_ready(monkeypatch, tmp_path):
manager = _manager(monkeypatch, tmp_path)
manager._root.mkdir(parents=True)
manager._install_file.write_text("{}", encoding="utf-8")
manager._write_state(state="installing", version="v3.1.0")
manager.cancel_install("restart failed")
status = manager.get_status()
assert status.state == "ready"
assert status.can_install is True
assert status.error == "restart failed"
assert not manager._install_file.exists()
def test_apply_prepared_application_replaces_docker_payload_and_preserves_plugins(
monkeypatch, tmp_path
):
"""Docker root worker 应替换前后端目录,同时保留运行时插件和站点资源。"""
manager = _docker_manager(monkeypatch, tmp_path)
app_dir = manager._docker_app_dir
public_dir = manager._docker_public_dir
plugin_dir = app_dir / "app" / "plugins"
resource_dir = app_dir / "app" / "application" / "site"
plugin_dir.mkdir(parents=True)
resource_dir.mkdir(parents=True)
public_dir.mkdir()
(plugin_dir / "__init__.py").write_text("# compatibility\n", encoding="utf-8")
(plugin_dir / "local_plugin.py").write_text("local\n", encoding="utf-8")
(resource_dir / "user.sites.v3.bin").write_text("old-resource\n", encoding="utf-8")
(resource_dir / "legacy.py").write_text("old-source\n", encoding="utf-8")
(app_dir / "old.py").write_text("old\n", encoding="utf-8")
(app_dir / "pyproject.toml").write_text("old-project\n", encoding="utf-8")
(app_dir / "uv.lock").write_text("old-lock\n", encoding="utf-8")
(public_dir / "index.html").write_text("old-front\n", encoding="utf-8")
manager._root.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(manager._backend_archive, "w") as archive:
archive.writestr(
"MoviePilot-v3.1.0/version.py",
"APP_VERSION = 'v3.1.0'\nFRONTEND_VERSION = 'v3.1.0'\n",
)
archive.writestr("MoviePilot-v3.1.0/pyproject.toml", "[project]\n")
archive.writestr("MoviePilot-v3.1.0/uv.lock", "version = 1\n")
archive.writestr("MoviePilot-v3.1.0/new.py", "new\n")
archive.writestr(
"MoviePilot-v3.1.0/app/plugins/__init__.py",
"new compatibility\n",
)
archive.writestr(
"MoviePilot-v3.1.0/app/application/site/__init__.py",
"",
)
archive.writestr(
"MoviePilot-v3.1.0/app/application/site/auth.py",
"new-auth\n",
)
with zipfile.ZipFile(manager._frontend_archive, "w") as archive:
archive.writestr("dist/index.html", "new-front\n")
archive.writestr("dist/version.txt", "v3.1.0\n")
prepared = {
"targets": ["application"],
"version": "v3.1.0",
"frontend_version": "v3.1.0",
"backend_archive": str(manager._backend_archive),
"frontend_archive": str(manager._frontend_archive),
"backend_sha256": manager._sha256(manager._backend_archive),
"frontend_sha256": manager._sha256(manager._frontend_archive),
}
(manager._root / "prepared.json").write_text(
json.dumps(prepared), encoding="utf-8"
)
manager._install_file.write_text(json.dumps(prepared), encoding="utf-8")
sync_calls = []
monkeypatch.setattr(
manager,
"_sync_docker_dependencies",
lambda project_dir, **kwargs: sync_calls.append((project_dir, kwargs)),
)
success, message = manager.apply_prepared_update()
assert success is True
assert message == "已下载的更新已替换到 Docker 程序目录"
assert len(sync_calls) == 1
assert sync_calls[0][0].name == "App"
assert sync_calls[0][1] == {}
assert (app_dir / "new.py").read_text(encoding="utf-8") == "new\n"
assert not (app_dir / "old.py").exists()
assert (app_dir / "app" / "plugins" / "__init__.py").read_text(
encoding="utf-8"
) == "new compatibility\n"
assert (app_dir / "app" / "plugins" / "local_plugin.py").exists()
assert (resource_dir / "user.sites.v3.bin").read_text(encoding="utf-8") == "old-resource\n"
assert (resource_dir / "auth.py").read_text(encoding="utf-8") == "new-auth\n"
assert not (resource_dir / "legacy.py").exists()
assert (public_dir / "index.html").read_text(encoding="utf-8") == "new-front\n"
assert not manager._install_file.exists()
assert not (manager._root / "prepared.json").exists()
assert not manager._docker_pending_file.exists()
assert not manager._docker_previous_app_dir.exists()
assert not manager._docker_previous_public_dir.exists()
@pytest.mark.parametrize("failure", [None, "backup", "install"])
def test_apply_prepared_resources_replaces_complete_docker_resource_package(
monkeypatch, tmp_path, failure
):
"""镜像层目录禁止重命名时仍能更新,备份或安装失败则保留旧资源。"""
manager = _docker_manager(monkeypatch, tmp_path)
monkeypatch.setattr(
update_module.ResourceHelper,
"_get_needed_files",
classmethod(lambda cls: ["user.sites.v3.bin", "sites.cpython-test.so"]),
)
monkeypatch.setattr(update_module, "get_resource_versions", lambda: ("1", "1"))
resource_dir = manager._docker_app_dir / "app" / "application" / "site"
resource_dir.mkdir(parents=True)
(manager._docker_app_dir / "keep.py").parent.mkdir(parents=True, exist_ok=True)
(manager._docker_app_dir / "keep.py").write_text("keep\n", encoding="utf-8")
(resource_dir / "user.sites.v3.bin").write_bytes(b"old-index")
(resource_dir / "sites.cpython-old.so").write_bytes(b"old-native")
prepared_files = []
for name, content in (
("user.sites.v3.bin", b"new-index"),
("sites.cpython-test.so", b"new-native"),
):
path = manager._resource_dir / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
prepared_files.append(
{"name": name, "path": str(path), "sha256": manager._sha256(path)}
)
prepared = {
"targets": ["resources"],
"resource_package_version": "10",
"resource_files": prepared_files,
}
manager._root.mkdir(parents=True, exist_ok=True)
(manager._root / "prepared.json").write_text(
json.dumps(prepared), encoding="utf-8"
)
manager._install_file.write_text(json.dumps(prepared), encoding="utf-8")
original_replace = Path.replace
original_copytree = update_module.shutil.copytree
def replace(source, target):
"""模拟 OverlayFS 镜像层重命名限制及新资源提交失败。"""
if source == resource_dir:
raise OSError(errno.EXDEV, "Invalid cross-device link")
if failure == "install" and source.parent.name.startswith(".moviepilot-resource-update-"):
raise OSError(errno.EIO, "install failed")
return original_replace(source, target)
def copytree(source, target, *args, **kwargs):
"""模拟备份复制失败,确保尚未触碰运行目录。"""
if failure == "backup" and Path(target).name.endswith(".__prepared_previous__"):
raise OSError(errno.ENOSPC, "backup failed")
return original_copytree(source, target, *args, **kwargs)
monkeypatch.setattr(Path, "replace", replace)
monkeypatch.setattr(update_module.shutil, "copytree", copytree)
success, _message = manager.apply_prepared_update()
if failure:
assert success is False
assert (resource_dir / "user.sites.v3.bin").read_bytes() == b"old-index"
assert (resource_dir / "sites.cpython-old.so").read_bytes() == b"old-native"
assert not (resource_dir / "sites.cpython-test.so").exists()
assert (manager._root / "prepared.json").exists()
return
assert success is True
assert (manager._docker_app_dir / "keep.py").read_text(encoding="utf-8") == "keep\n"
assert (resource_dir / "user.sites.v3.bin").read_bytes() == b"new-index"
assert (resource_dir / "sites.cpython-test.so").read_bytes() == b"new-native"
assert not (resource_dir / "sites.cpython-old.so").exists()
assert not manager._install_file.exists()
assert not (manager._root / "prepared.json").exists()