Files
MoviePilot/tests/test_database_backup_service.py

257 lines
8.1 KiB
Python

from __future__ import annotations
import stat
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from threading import Event
import pytest
from app.adapters.system.backup.files import BackupFiles
from app.application.backup import (
BackupCheck,
BackupPolicy,
DatabaseBackupInProgressError,
DatabaseBackupService,
)
from version import APP_VERSION
@dataclass(frozen=True, slots=True)
class _Check:
valid: bool
method: str = "test-check"
detail: str | None = None
class _Backend:
db_type = "sqlite"
suffix = ".db"
def __init__(self, *, valid: bool = True) -> None:
self.valid = valid
self.restored: Path | None = None
def create(self, destination: Path) -> None:
destination.write_bytes(b"database snapshot")
def verify(self, artifact: Path) -> BackupCheck:
return _Check(self.valid and artifact.read_bytes() == b"database snapshot")
def restore(self, artifact: Path) -> None:
self.restored = artifact
class _BlockingBackend(_Backend):
"""让首个创建停在后端写入阶段,以验证共享服务的并发约束。"""
def __init__(self) -> None:
super().__init__()
self.started = Event()
self.release = Event()
def create(self, destination: Path) -> None:
self.started.set()
if not self.release.wait(timeout=2):
raise TimeoutError("测试未释放数据库备份")
super().create(destination)
def _service(
root: Path,
*,
backend: _Backend | None = None,
now: datetime | None = None,
retention_days: int = 0,
max_count: int = 0,
) -> DatabaseBackupService:
return DatabaseBackupService(
backend=backend or _Backend(),
artifact_store_factory=BackupFiles,
policy_reader=lambda: BackupPolicy(root, retention_days, max_count),
clock=lambda: now or datetime(2026, 8, 19, 13, 45, 26),
)
def test_service_builds_artifact_store_from_current_policy_root(tmp_path: Path) -> None:
"""每次操作都应把最新策略根目录交给注入的存储工厂。"""
roots: list[Path] = []
def build_store(root: Path) -> BackupFiles:
"""记录应用传入的根目录并返回真实受限文件适配器。"""
roots.append(root)
return BackupFiles(root)
service = DatabaseBackupService(
backend=_Backend(),
artifact_store_factory=build_store,
policy_reader=lambda: BackupPolicy(tmp_path),
clock=lambda: datetime(2026, 8, 19, 13, 45, 26),
)
artifact = service.create()
assert roots == [tmp_path]
assert artifact.path.parent == tmp_path
def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
artifact = _service(tmp_path).create()
assert artifact.name == f"moviepilot_{APP_VERSION}_sqlite_20260819_134526.db"
assert artifact.path.read_bytes() == b"database snapshot"
assert stat.S_IMODE(tmp_path.stat().st_mode) == 0o700
assert stat.S_IMODE(artifact.path.stat().st_mode) == 0o600
assert not list(tmp_path.glob("*.partial"))
def test_failed_verification_does_not_publish_artifact(tmp_path: Path) -> None:
backend = _Backend(valid=False)
service = _service(tmp_path, backend=backend)
with pytest.raises(RuntimeError, match="数据库备份校验失败"):
service.create()
assert list(tmp_path.iterdir()) == []
backend.valid = True
assert service.create().path.is_file()
def test_concurrent_create_is_rejected_without_waiting(tmp_path: Path) -> None:
backend = _BlockingBackend()
service = _service(tmp_path, backend=backend)
with ThreadPoolExecutor(max_workers=1) as executor:
first = executor.submit(service.create)
assert backend.started.wait(timeout=1)
with pytest.raises(DatabaseBackupInProgressError, match="正在执行"):
service.create()
backend.release.set()
artifact = first.result(timeout=2)
assert artifact.path.is_file()
def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> None:
service = _service(tmp_path)
first = service.create()
second = service.create()
assert first.name == f"moviepilot_{APP_VERSION}_sqlite_20260819_134526.db"
assert second.name == f"moviepilot_{APP_VERSION}_sqlite_20260819_134526_1.db"
def test_backup_name_uses_application_release_version(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"app.adapters.system.backup.files.get_app_version",
lambda: "v4.2.1",
)
assert _service(tmp_path).create().name == "moviepilot_v4.2.1_sqlite_20260819_134526.db"
def test_backup_name_uses_plugin_target_and_version(tmp_path: Path) -> None:
"""插件备份文件名应记录插件 ID 与插件版本,而不是宿主版本。"""
service = DatabaseBackupService(
backend=_Backend(),
artifact_store_factory=BackupFiles,
policy_reader=lambda: BackupPolicy(tmp_path),
clock=lambda: datetime(2026, 8, 19, 13, 45, 26),
target="DemoPlugin",
version="1.2.3",
)
artifact = service.create()
assert artifact.name == "DemoPlugin_v1.2.3_sqlite_20260819_134526.db"
assert artifact.target == "DemoPlugin"
def test_plugin_backup_without_version_uses_target_without_version_segment(
tmp_path: Path,
) -> None:
"""插件版本元数据缺失时仍应备份,并使用可解析的无版本文件名。"""
service = DatabaseBackupService(
backend=_Backend(),
artifact_store_factory=BackupFiles,
policy_reader=lambda: BackupPolicy(tmp_path),
clock=lambda: datetime(2026, 8, 19, 13, 45, 26),
target="DemoPlugin",
version=None,
)
artifact = service.create()
assert artifact.name == "DemoPlugin_sqlite_20260819_134526.db"
assert artifact.target == "DemoPlugin"
assert BackupFiles.target(artifact.name) == "DemoPlugin"
def test_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None:
old = _service(tmp_path, now=datetime(2026, 8, 1, 3, 0, 0)).create()
current = _service(
tmp_path,
now=datetime(2026, 8, 19, 3, 0, 0),
retention_days=7,
max_count=1,
).create()
assert current.path.exists()
assert not old.path.exists()
def test_list_ignores_unmanaged_files_and_rejects_paths(tmp_path: Path) -> None:
artifact = _service(tmp_path).create()
(tmp_path / "notes.txt").write_text("ignore", encoding="utf-8")
assert [item.name for item in _service(tmp_path).list()] == [artifact.name]
with pytest.raises(ValueError, match="文件名"):
_service(tmp_path).verify("../user.db")
def test_legacy_backup_name_remains_managed(tmp_path: Path) -> None:
"""升级前生成的无代际前缀备份仍可列出、校验和删除。"""
legacy = tmp_path / "sqlite_20260818_030000.db"
legacy.write_bytes(b"database snapshot")
service = _service(tmp_path)
assert [item.name for item in service.list()] == [legacy.name]
assert service.verify(legacy.name).valid is True
service.delete(legacy.name)
assert legacy.exists() is False
def test_delete_removes_only_named_managed_backup(tmp_path: Path) -> None:
service = _service(tmp_path)
artifact = service.create()
unmanaged = tmp_path / "notes.txt"
unmanaged.write_text("keep", encoding="utf-8")
service.delete(artifact.name)
assert artifact.path.exists() is False
assert unmanaged.exists() is True
with pytest.raises(ValueError, match="文件名"):
service.delete("../notes.txt")
def test_restore_requires_matching_database_type(tmp_path: Path) -> None:
backend = _Backend()
service = _service(tmp_path, backend=backend)
artifact = service.create()
restored = service.restore(artifact.name)
assert restored.name == artifact.name
assert backend.restored == artifact.path
postgres = tmp_path / "moviepilot_v3.0.0_postgresql_20260819_134526.dump"
postgres.write_bytes(b"database snapshot")
with pytest.raises(ValueError, match="当前数据库类型"):
service.restore(postgres.name)