mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-11 16:21:09 +08:00
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""系统后台更新状态机测试。"""
|
||
|
||
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()
|