feat: 完善数据库备份管理与版本读取 (#6450)

This commit is contained in:
InfinityPacer
2026-08-25 16:05:45 +08:00
committed by GitHub
parent b952a3e407
commit d58c8d2b17
38 changed files with 1846 additions and 459 deletions
+81 -6
View File
@@ -1,13 +1,19 @@
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.application.backup import BackupPolicy, DatabaseBackupService
from app.application.backup import (
BackupPolicy,
DatabaseBackupInProgressError,
DatabaseBackupService,
)
@dataclass(frozen=True, slots=True)
@@ -35,6 +41,21 @@ class _Backend:
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,
*,
@@ -53,7 +74,7 @@ def _service(
def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
artifact = _service(tmp_path).create()
assert artifact.name == "sqlite_20260819_134526.db"
assert artifact.name == "moviepilot_v3.0.0_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
@@ -61,10 +82,29 @@ def test_create_publishes_one_readable_private_file(tmp_path: Path) -> None:
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(tmp_path, backend=_Backend(valid=False)).create()
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:
@@ -73,8 +113,17 @@ def test_same_second_backups_receive_short_sequence_suffix(tmp_path: Path) -> No
first = service.create()
second = service.create()
assert first.name == "sqlite_20260819_134526.db"
assert second.name == "sqlite_20260819_134526_1.db"
assert first.name == "moviepilot_v3.0.0_sqlite_20260819_134526.db"
assert second.name == "moviepilot_v3.0.0_sqlite_20260819_134526_1.db"
def test_backup_name_uses_application_release_version(tmp_path: Path, 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_retention_applies_after_new_artifact_is_available(tmp_path: Path) -> None:
@@ -99,6 +148,32 @@ def test_list_ignores_unmanaged_files_and_rejects_paths(tmp_path: Path) -> None:
_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)
@@ -109,7 +184,7 @@ def test_restore_requires_matching_database_type(tmp_path: Path) -> None:
assert restored.name == artifact.name
assert backend.restored == artifact.path
postgres = tmp_path / "postgresql_20260819_134526.dump"
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)