mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 15:38:19 +08:00
feat(system): add staged release updates
This commit is contained in:
+20
-3
@@ -13,8 +13,8 @@
|
||||
"runtime_to_db": [],
|
||||
"workflow_to_db": []
|
||||
},
|
||||
"edge_count": 6579,
|
||||
"edge_sha256": "87982f9e351a23cb949bcb8a978b9c1eced6260d6c19761fccc9557b79ac90d3",
|
||||
"edge_count": 6595,
|
||||
"edge_sha256": "b0709f6e54bf046386d5df81c54a3e65889f18473b8cc91bbe7c97010bda02dc",
|
||||
"edges": [
|
||||
"app -> app.runtime",
|
||||
"app -> app.runtime.compat",
|
||||
@@ -171,6 +171,19 @@
|
||||
"app.adapters.system.rust -> app.runtime",
|
||||
"app.adapters.system.rust -> app.runtime.log",
|
||||
"app.adapters.system.rust -> app.runtime.settings",
|
||||
"app.adapters.system.update -> app.adapters",
|
||||
"app.adapters.system.update -> app.adapters.network",
|
||||
"app.adapters.system.update -> app.adapters.network.http",
|
||||
"app.adapters.system.update -> app.foundation",
|
||||
"app.adapters.system.update -> app.foundation.environment",
|
||||
"app.adapters.system.update -> app.foundation.singleton",
|
||||
"app.adapters.system.update -> app.foundation.version",
|
||||
"app.adapters.system.update -> app.runtime",
|
||||
"app.adapters.system.update -> app.runtime.log",
|
||||
"app.adapters.system.update -> app.runtime.settings",
|
||||
"app.adapters.system.update -> app.runtime.thread",
|
||||
"app.adapters.system.update -> app.schemas",
|
||||
"app.adapters.system.update -> app.schemas.system",
|
||||
"app.adapters.web.correlation -> app.runtime",
|
||||
"app.adapters.web.correlation -> app.runtime.correlation",
|
||||
"app.adapters.web.health -> app.runtime",
|
||||
@@ -2263,6 +2276,7 @@
|
||||
"app.api.endpoints.system -> app.adapters.network.http",
|
||||
"app.api.endpoints.system -> app.adapters.system",
|
||||
"app.api.endpoints.system -> app.adapters.system.rust",
|
||||
"app.api.endpoints.system -> app.adapters.system.update",
|
||||
"app.api.endpoints.system -> app.adapters.web",
|
||||
"app.api.endpoints.system -> app.adapters.web.security",
|
||||
"app.api.endpoints.system -> app.adapters.web.security.access",
|
||||
@@ -5830,6 +5844,8 @@
|
||||
"app.scheduler -> app.adapters",
|
||||
"app.scheduler -> app.adapters.external",
|
||||
"app.scheduler -> app.adapters.external.server",
|
||||
"app.scheduler -> app.adapters.system",
|
||||
"app.scheduler -> app.adapters.system.update",
|
||||
"app.scheduler -> app.application",
|
||||
"app.scheduler -> app.application.agent",
|
||||
"app.scheduler -> app.application.agentdata",
|
||||
@@ -6596,7 +6612,7 @@
|
||||
"app.workflow.actions.transfer_file -> app.workflow",
|
||||
"app.workflow.actions.transfer_file -> app.workflow.actions"
|
||||
],
|
||||
"module_count": 812,
|
||||
"module_count": 813,
|
||||
"modules": [
|
||||
"app",
|
||||
"app.adapters",
|
||||
@@ -6637,6 +6653,7 @@
|
||||
"app.adapters.system.resource",
|
||||
"app.adapters.system.rust",
|
||||
"app.adapters.system.stdio",
|
||||
"app.adapters.system.update",
|
||||
"app.adapters.web",
|
||||
"app.adapters.web.correlation",
|
||||
"app.adapters.web.health",
|
||||
|
||||
+116
-69
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
@@ -15,12 +16,8 @@ MODULE_PATH = Path(__file__).resolve().parents[1] / "app" / "cli.py"
|
||||
|
||||
class _DummySystemHelper:
|
||||
@staticmethod
|
||||
def consume_one_shot_update_mode():
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_auto_update_mode():
|
||||
return "false"
|
||||
def consume_one_shot_dev_update():
|
||||
return False
|
||||
|
||||
|
||||
def load_cli_module():
|
||||
@@ -39,6 +36,7 @@ def load_cli_module():
|
||||
PROXY_HOST="",
|
||||
PIP_PROXY="",
|
||||
GITHUB_TOKEN="",
|
||||
MOVIEPILOT_AUTO_UPDATE="false",
|
||||
PROXY={},
|
||||
REPO_GITHUB_HEADERS=lambda _repo: {},
|
||||
)
|
||||
@@ -84,74 +82,123 @@ def load_cli_module():
|
||||
return module
|
||||
|
||||
|
||||
class CliAutoUpdateTests(unittest.TestCase):
|
||||
def test_resolve_auto_update_targets_only_queries_backend_release(self):
|
||||
module = load_cli_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
|
||||
|
||||
with patch.object(module, "_latest_release_tag", return_value="v2.10.12") as latest_mock:
|
||||
backend_ref = module._resolve_auto_update_targets("release")
|
||||
|
||||
latest_mock.assert_called_once_with(
|
||||
module.BACKEND_RELEASES_API,
|
||||
repo="jxxghp/MoviePilot",
|
||||
prefix="v2",
|
||||
)
|
||||
self.assertEqual(backend_ref, "v2.10.12")
|
||||
def test_one_shot_dev_update_overrides_disabled_default():
|
||||
module = load_cli_module()
|
||||
module.settings.MOVIEPILOT_AUTO_UPDATE = "false"
|
||||
|
||||
def test_best_effort_auto_update_does_not_pass_frontend_version_override(self):
|
||||
module = load_cli_module()
|
||||
run_result = SimpleNamespace(returncode=0, stdout="ok")
|
||||
with patch.object(
|
||||
module.SystemHelper, "consume_one_shot_dev_update", return_value=True
|
||||
):
|
||||
assert module._auto_update_mode() == "dev"
|
||||
|
||||
with patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
|
||||
module, "_resolve_auto_update_targets", return_value="v2.10.12"
|
||||
), 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]
|
||||
self.assertEqual(command[1:5], [str(module._repo_root() / "scripts" / "local_setup.py"), "update", "all", "--ref"])
|
||||
self.assertNotIn("--frontend-version", command)
|
||||
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_best_effort_auto_update_passes_package_env_and_overrides_proxy(self):
|
||||
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="release"
|
||||
), patch.object(module, "_resolve_auto_update_targets", return_value="v2.10.12"), 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"]
|
||||
self.assertEqual(env["HTTPS_PROXY"], "http://proxy.example:7890")
|
||||
self.assertEqual(env["PIP_PROXY"], "https://mirror.example/simple")
|
||||
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(module.settings.PACKAGE_CACHE_PATH))
|
||||
self.assertEqual(env["UV_CACHE_DIR"], str(module.settings.PACKAGE_CACHE_PATH / "uv"))
|
||||
|
||||
def test_best_effort_auto_update_derives_tool_cache_from_existing_root(self):
|
||||
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,
|
||||
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(
|
||||
{
|
||||
"PACKAGE_CACHE_ROOT": str(package_cache_root),
|
||||
},
|
||||
clear=True,
|
||||
), patch.object(module, "_auto_update_mode", return_value="release"), patch.object(
|
||||
module, "_resolve_auto_update_targets", return_value="v2.10.12"
|
||||
), patch.object(module.subprocess, "run", return_value=run_result) as run_mock, patch.object(
|
||||
module.click, "echo"
|
||||
):
|
||||
module._best_effort_auto_update()
|
||||
"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")
|
||||
|
||||
env = run_mock.call_args.kwargs["env"]
|
||||
self.assertEqual(env["PACKAGE_CACHE_ROOT"], str(package_cache_root))
|
||||
self.assertEqual(env["UV_CACHE_DIR"], str(package_cache_root / "uv"))
|
||||
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_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")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
@@ -761,7 +763,64 @@ def test_updater_exposes_explicit_result(
|
||||
assert result.stdout == f"{expected}\n"
|
||||
|
||||
|
||||
def test_release_noop_preserves_prerelease_selection_without_probing_package_index(
|
||||
def test_prepared_release_is_verified_and_installed_without_release_lookup(tmp_path: Path) -> None:
|
||||
config_dir = tmp_path / "config"
|
||||
update_root = config_dir / "temp" / "moviepilot-update"
|
||||
update_root.mkdir(parents=True)
|
||||
backend = update_root / "backend.zip"
|
||||
frontend = update_root / "frontend.zip"
|
||||
backend.write_bytes(b"backend-package")
|
||||
frontend.write_bytes(b"frontend-package")
|
||||
backend_sha256 = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
frontend_sha256 = hashlib.sha256(frontend.read_bytes()).hexdigest()
|
||||
(update_root / "install.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": "v3.1.0",
|
||||
"frontend_version": "v3.1.0",
|
||||
"backend_archive": str(backend),
|
||||
"frontend_archive": str(frontend),
|
||||
"backend_sha256": backend_sha256,
|
||||
"frontend_sha256": frontend_sha256,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
release_probe = tmp_path / "release-probe"
|
||||
script = textwrap.dedent(
|
||||
f"""\
|
||||
CONFIG_DIR="$1"
|
||||
MOVIEPILOT_AUTO_UPDATE=release
|
||||
PIP_PROXY= PROXY_HOST= GITHUB_PROXY= GITHUB_TOKEN=
|
||||
RELEASE_PROBE="$2"
|
||||
source {UPDATER!s}
|
||||
INFO() {{ :; }}
|
||||
WARN() {{ :; }}
|
||||
ERROR() {{ :; }}
|
||||
test_connectivity_github() {{ touch "${{RELEASE_PROBE}}"; return 1; }}
|
||||
install_backend_and_download_resources() {{
|
||||
test "${{MOVIEPILOT_PREPARED_UPDATE}}" = true
|
||||
test "$1" = tags/v3.1.0.zip
|
||||
MOVIEPILOT_UPDATE_RESULT=updated
|
||||
}}
|
||||
run_moviepilot_update
|
||||
printf '%s\n' "${{MOVIEPILOT_UPDATE_RESULT}}"
|
||||
"""
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "prepared-update-test", str(config_dir), str(release_probe)],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert result.stdout == "updated\n"
|
||||
assert not release_probe.exists()
|
||||
assert not (update_root / "install.json").exists()
|
||||
|
||||
|
||||
def test_release_mode_no_longer_checks_or_installs_during_restart(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
package_probe = tmp_path / "package-probe"
|
||||
@@ -823,14 +882,8 @@ def test_release_noop_preserves_prerelease_selection_without_probing_package_ind
|
||||
|
||||
assert result.stdout == "noop\n"
|
||||
assert not package_probe.exists()
|
||||
curl_args = curl_log.read_text(encoding="utf-8")
|
||||
assert "/releases" in curl_args
|
||||
assert "/releases/latest" not in curl_args
|
||||
assert "--compressed" in curl_args
|
||||
assert "--fail" in curl_args
|
||||
assert "--connect-timeout 5" in curl_args
|
||||
assert "--max-time 15" in curl_args
|
||||
assert comparison_log.read_text(encoding="utf-8") == "v3.0.0|v3.1.0-rc\n"
|
||||
assert not curl_log.exists()
|
||||
assert not comparison_log.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -916,7 +916,7 @@ def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
|
||||
asyncio.run(server.serve())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("endpoint_name", ["restart_system", "upgrade_system"])
|
||||
@pytest.mark.parametrize("endpoint_name", ["restart_system", "install_system_update"])
|
||||
@pytest.mark.parametrize(
|
||||
"initially_stopped",
|
||||
[False, True],
|
||||
@@ -927,7 +927,7 @@ def test_restart_endpoint_failure_preserves_stop_state(
|
||||
endpoint_name,
|
||||
initially_stopped,
|
||||
):
|
||||
"""重启或升级失败不能发布或撤销停止请求"""
|
||||
"""重启或更新安装失败不能发布或撤销停止请求"""
|
||||
from app.api.endpoints import system
|
||||
|
||||
stop_event = threading.Event()
|
||||
@@ -935,19 +935,45 @@ def test_restart_endpoint_failure_preserves_stop_state(
|
||||
stop_event.set()
|
||||
monkeypatch.setattr(system.global_vars, "STOP_EVENT", stop_event)
|
||||
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
|
||||
monkeypatch.setattr(system.SystemHelper, "restart", MagicMock(return_value=(False, "restart failed")))
|
||||
monkeypatch.setattr(
|
||||
system.SystemHelper,
|
||||
"restart" if endpoint_name == "restart_system" else "upgrade",
|
||||
MagicMock(return_value=(False, "restart failed")),
|
||||
system.system_update_manager,
|
||||
"request_install",
|
||||
MagicMock(return_value=(True, "prepared")),
|
||||
)
|
||||
cancel_install = MagicMock()
|
||||
monkeypatch.setattr(system.system_update_manager, "cancel_install", cancel_install)
|
||||
|
||||
if endpoint_name == "restart_system":
|
||||
response = system.restart_system(None)
|
||||
else:
|
||||
response = system.upgrade_system(None, None)
|
||||
response = system.install_system_update(None)
|
||||
|
||||
assert not response.success
|
||||
assert stop_event.is_set() is initially_stopped
|
||||
if endpoint_name == "install_system_update":
|
||||
cancel_install.assert_called_once_with("restart failed")
|
||||
else:
|
||||
cancel_install.assert_not_called()
|
||||
|
||||
|
||||
def test_upgrade_endpoint_retains_dev_mode_only(monkeypatch):
|
||||
"""旧升级入口只保留 Dev,Release 必须迁移到后台下载流程。"""
|
||||
from app.api.endpoints import system
|
||||
|
||||
monkeypatch.setattr(system.SystemHelper, "can_restart", MagicMock(return_value=True))
|
||||
upgrade_dev = MagicMock(return_value=(True, "dev queued"))
|
||||
monkeypatch.setattr(system.SystemHelper, "upgrade_dev", upgrade_dev)
|
||||
|
||||
dev_response = system.upgrade_system("dev", None)
|
||||
release_response = system.upgrade_system("release", None)
|
||||
legacy_default_response = system.upgrade_system(None, None)
|
||||
|
||||
assert dev_response.success
|
||||
assert not release_response.success
|
||||
assert not legacy_default_response.success
|
||||
assert "update/check" in release_response.message
|
||||
upgrade_dev.assert_called_once_with()
|
||||
|
||||
|
||||
def test_command_restart_failure_does_not_publish_stop_request(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""系统后台更新状态机测试。"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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
|
||||
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)
|
||||
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, "APP_VERSION", "v3.0.0")
|
||||
|
||||
status = manager.check()
|
||||
|
||||
assert status.state == "available"
|
||||
assert status.version == "v3.1.0"
|
||||
assert status.release_notes == "changes"
|
||||
assert status.can_update is True
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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._download_update("v3.1.0")
|
||||
|
||||
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_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()
|
||||
@@ -15,7 +15,7 @@ import psutil
|
||||
import pytest
|
||||
|
||||
from app.runtime.state import SystemHelper
|
||||
from app.runtime.config import ConfigModel, settings
|
||||
from app.runtime.config import ConfigModel, Settings, settings
|
||||
from app.adapters.system.host import SystemUtils
|
||||
|
||||
|
||||
@@ -523,6 +523,26 @@ def test_btrfs_fsid_dedup_setting_is_opt_in():
|
||||
assert ConfigModel(BTRFS_FSID_DEDUP="true").BTRFS_FSID_DEDUP is True
|
||||
|
||||
|
||||
def test_legacy_release_auto_update_mode_is_disabled(monkeypatch):
|
||||
"""历史 Release 启动更新值迁移为关闭,Dev 值继续保留。"""
|
||||
updates = []
|
||||
monkeypatch.setattr(
|
||||
Settings,
|
||||
"update_env_config",
|
||||
lambda field, original, converted: updates.append(
|
||||
(field, original, converted)
|
||||
),
|
||||
)
|
||||
|
||||
assert Settings(MOVIEPILOT_AUTO_UPDATE="release").MOVIEPILOT_AUTO_UPDATE == "false"
|
||||
assert Settings(MOVIEPILOT_AUTO_UPDATE="true").MOVIEPILOT_AUTO_UPDATE == "false"
|
||||
assert Settings(MOVIEPILOT_AUTO_UPDATE="dev").MOVIEPILOT_AUTO_UPDATE == "dev"
|
||||
assert updates == [
|
||||
("MOVIEPILOT_AUTO_UPDATE", "release", "false"),
|
||||
("MOVIEPILOT_AUTO_UPDATE", "true", "false"),
|
||||
]
|
||||
|
||||
|
||||
def test_space_usage_default_path_does_not_read_fsid():
|
||||
with tempfile.TemporaryDirectory() as tmp1, tempfile.TemporaryDirectory() as tmp2:
|
||||
paths = [Path(tmp1), Path(tmp2)]
|
||||
|
||||
Reference in New Issue
Block a user