Files
MoviePilot/tests/test_system_update_manager.py
2026-09-02 07:42:04 +08:00

294 lines
10 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 json
import threading
import zipfile
from pathlib import Path
from types import SimpleNamespace
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 _response(payload, status_code=200):
return SimpleNamespace(status_code=status_code, json=lambda: payload)
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_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_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()