""" 系统数据库备份 Web 管理端点测试。 """ from __future__ import annotations import asyncio from datetime import datetime from pathlib import Path from threading import Event from types import SimpleNamespace import httpx import pytest from fastapi import FastAPI, HTTPException from app.api.dependencies.auth import get_current_active_superuser_async from app.api.endpoints import system as system_endpoint from app.application.backup import ( BackupVerification, DatabaseBackupInProgressError, ) class _Governance: """ 提供端点测试所需的最小数据库治理合同。 """ def __init__(self) -> None: self.artifact = SimpleNamespace( name="moviepilot_v3.0.0_sqlite_20260825_120000.db", db_type="sqlite", created_at=datetime(2026, 8, 25, 12, 0, 0), path=Path("/private/database_backup/moviepilot_v3.0.0_sqlite_20260825_120000.db"), size=4096, ) def list_backups(self): """ 返回预设备份条目,避免接口测试访问真实备份目录。 """ return (self.artifact,) def create_backup(self): """ 返回预设新备份条目,不执行真实数据库备份。 """ return self.artifact def verify_backup(self, name: str): """ 拒绝带路径的备份名称,否则返回含内部详情的成功校验结果。 """ if "/" in name: raise ValueError("数据库备份文件名不能包含路径") return BackupVerification(True, "PRAGMA integrity_check", "private detail") def delete_backup(self, name: str): """ 拒绝带路径的备份名称,并为指定缺失文件模拟不存在错误。 """ if "/" in name: raise ValueError("数据库备份文件名不能包含路径") if name == "missing.db": raise FileNotFoundError(name) @pytest.mark.asyncio async def test_list_database_backups_maps_public_fields(monkeypatch) -> None: """ 列表不得把内部备份路径投影到 Web 响应。 """ monkeypatch.setattr(system_endpoint, "get_database_governance", _Governance) result = await system_endpoint.list_database_backups(_=object()) assert [item.model_dump() for item in result] == [ { "name": "moviepilot_v3.0.0_sqlite_20260825_120000.db", "db_type": "sqlite", "created_at": datetime(2026, 8, 25, 12, 0, 0), "size": 4096, } ] @pytest.mark.asyncio async def test_create_database_backup_reports_busy_state(monkeypatch) -> None: """ 重复创建应返回冲突,不排队创建第二份备份。 """ governance = _Governance() monkeypatch.setattr( governance, "create_backup", lambda: (_ for _ in ()).throw( DatabaseBackupInProgressError("已有数据库备份任务正在执行") ), ) monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) with pytest.raises(HTTPException) as error: await system_endpoint.create_database_backup(_=object()) assert error.value.status_code == 409 assert error.value.detail == "已有数据库备份任务正在执行" @pytest.mark.asyncio async def test_verify_database_backup_rejects_path_input(monkeypatch) -> None: """ 校验端点只接受受管文件名。 """ monkeypatch.setattr(system_endpoint, "get_database_governance", _Governance) with pytest.raises(HTTPException) as error: await system_endpoint.verify_database_backup("../user.db", _=object()) assert error.value.status_code == 400 assert error.value.detail == "数据库备份文件名无效" @pytest.mark.asyncio async def test_delete_database_backup_accepts_only_managed_name(monkeypatch) -> None: """ 删除备份接口只接受受管文件名,并将非法名称转为客户端错误。 """ governance = _Governance() monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) response = await system_endpoint.delete_database_backup( governance.artifact.name, _=object(), ) assert response.success is True with pytest.raises(HTTPException) as invalid: await system_endpoint.delete_database_backup("../user.db", _=object()) assert invalid.value.status_code == 400 @pytest.mark.asyncio async def test_delete_database_backup_uses_host_response_envelope(monkeypatch) -> None: """ Web 客户端必须收到统一响应,不能把成功删除误判为空响应错误。 """ governance = _Governance() monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) app = FastAPI() app.include_router(system_endpoint.router, prefix="/api/v1/system") app.dependency_overrides[get_current_active_superuser_async] = lambda: object() transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://test", ) as client: response = await client.delete( f"/api/v1/system/database/backups/{governance.artifact.name}" ) assert response.status_code == 200 assert response.json() == {"success": True, "message": "", "data": None} @pytest.mark.asyncio async def test_list_database_backups_does_not_block_event_loop(monkeypatch) -> None: """ 文件系统或数据库工具等待必须在线程边界内执行。 """ started = Event() release = Event() governance = _Governance() def blocking_list(): started.set() if not release.wait(timeout=2): raise TimeoutError("测试未释放数据库备份列表") return (governance.artifact,) monkeypatch.setattr(governance, "list_backups", blocking_list) monkeypatch.setattr(system_endpoint, "get_database_governance", lambda: governance) task = asyncio.create_task(system_endpoint.list_database_backups(_=object())) for _ in range(100): if started.is_set(): break await asyncio.sleep(0.001) assert started.is_set() assert task.done() is False release.set() assert len(await task) == 1