| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664 |
- """备份功能(契约1.21):引擎链式备份、配置存储、保留策略与 HTTP 接口。
- 引擎级测试不依赖数据库(数据库导出以伪实现替代),始终执行;接口级测试沿用
- B2 惯例,提供 DMS_TEST_* 环境变量时执行,否则安全跳过。
- """
- from __future__ import annotations
- import json
- import time
- from pathlib import Path
- import pytest
- CONFIG_URL = "/api/v1/backup/config"
- JOBS_URL = "/api/v1/backup/jobs"
- CURRENT_URL = "/api/v1/backup/jobs/current"
- CANCEL_URL = "/api/v1/backup/jobs/current/cancel"
- PROGRESS_KEYS = {
- "backupId",
- "type",
- "trigger",
- "phase",
- "bytesTotal",
- "bytesCopied",
- "filesTotal",
- "filesCopied",
- "pushPercent",
- "error",
- "startedAt",
- "finishedAt",
- }
- HISTORY_ENTRY_KEYS = {
- "id",
- "type",
- "parent",
- "started_at",
- "finished_at",
- "database_file",
- "database_size",
- "transferred_files",
- "transferred_bytes",
- "storage_file_count",
- "storage_total_bytes",
- }
- # database_url 仅做字符串解析(伪造导出后不连库),任意含 user@host 的串即可
- _FAKE_DATABASE_URL = "dm+dmPython://DMS_TESTER:secret@localhost:5236/DMS_TESTER"
- def _fake_dump(monkeypatch) -> None:
- from dms.services import sql_dump
- def fake_export_dump(database_url, output_directory, schema):
- output_directory.mkdir(parents=True, exist_ok=True)
- dump_file = output_directory / sql_dump.DUMP_FILE_NAME
- dump_file.write_text("-- FAKE-DM-DATA-DUMP\n", encoding="utf-8")
- return dump_file
- monkeypatch.setattr(sql_dump, "export_dump", fake_export_dump)
- @pytest.fixture()
- def engine_env(monkeypatch, tmp_path: Path):
- """独立引擎(存储/配置/env/备份根全在 tmp_path),不依赖数据库。"""
- from dms.services.backup_service import BackupConfig, BackupEngine
- engine = BackupEngine(
- storage_root=tmp_path / "storage",
- business_timezone="Asia/Shanghai",
- database_url=_FAKE_DATABASE_URL,
- backup_config_path=tmp_path / "dms-backup-config.json",
- env_path=tmp_path / ".env",
- )
- (tmp_path / ".env").write_text("DMS_BACKUP_TEST_ENV=1\n", encoding="utf-8")
- backup_root = tmp_path / "backup-root"
- BackupConfig(local_path=str(backup_root)).save(engine.backup_config_path)
- _fake_dump(monkeypatch)
- engine.backup_root = backup_root # type: ignore[attr-defined]
- return engine
- @pytest.fixture()
- def backup_env(b2_app, monkeypatch, tmp_path: Path):
- """接口测试用:替换应用引擎(备份根/配置/环境文件均指向 tmp_path)。"""
- from dms.services.backup_service import BackupEngine
- b2_app.extensions["dms_backup_engine"].stop_scheduler()
- original = b2_app.extensions["dms_backup_engine"]
- engine = BackupEngine(
- storage_root=original.storage_root,
- business_timezone=b2_app.config["DMS_BUSINESS_TIMEZONE"],
- database_url=b2_app.config["SQLALCHEMY_DATABASE_URI"],
- backup_config_path=tmp_path / "dms-backup-config.json",
- env_path=tmp_path / ".env",
- )
- b2_app.extensions["dms_backup_engine"] = engine
- (tmp_path / ".env").write_text("DMS_BACKUP_TEST_ENV=1\n", encoding="utf-8")
- _fake_dump(monkeypatch)
- b2_app.config["BACKUP_TEST_ROOT"] = tmp_path / "backup-root"
- return engine
- def _auth(token: str) -> dict[str, str]:
- return {"Authorization": f"Bearer {token}"}
- def _save_local_config(client, token, backup_root: Path, **overrides):
- payload = {
- "target_mode": "LOCAL",
- "local_path": str(backup_root),
- "rsync_url": None,
- "rsync_password": None,
- "schedule_enabled": False,
- "schedule_time": "02:00",
- "full_interval_days": 7,
- "retain_full_count": 2,
- }
- payload.update(overrides)
- response = client.put(CONFIG_URL, json=payload, headers=_auth(token))
- assert response.status_code == 200
- return response.get_json()["data"]
- def _wait_finished(engine, timeout_seconds: float = 15.0) -> dict:
- deadline = time.monotonic() + timeout_seconds
- while engine.is_running or engine.progress_snapshot()["phase"] is None:
- if time.monotonic() > deadline:
- pytest.fail("备份任务超时未结束")
- time.sleep(0.05)
- return engine.progress_snapshot()
- def _write_storage_file(engine, relative: str, content: bytes) -> None:
- path = engine.storage_root / relative
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_bytes(content)
- # ================================================================ 引擎级(无数据库,始终执行)
- def test_engine_progress_snapshot_initial_shape(engine_env):
- snapshot = engine_env.progress_snapshot()
- assert set(snapshot) == PROGRESS_KEYS
- assert snapshot["backupId"] is None
- assert snapshot["phase"] is None
- assert snapshot["bytesTotal"] == 0
- assert snapshot["pushPercent"] is None
- def test_engine_full_and_incremental_chain(engine_env):
- engine = engine_env
- _write_storage_file(engine, "original/a.pdf", b"AAA")
- _write_storage_file(engine, "original/sub/b.docx", b"BBBB")
- _write_storage_file(engine, "preview/c.jpg", b"C")
- # 全量
- engine.start_async("FULL", "MANUAL")
- full_snapshot = _wait_finished(engine)
- assert full_snapshot["phase"] == "SUCCESS"
- assert full_snapshot["error"] is None
- full_id = full_snapshot["backupId"]
- assert full_id.endswith("-F")
- assert full_snapshot["trigger"] == "MANUAL"
- assert full_snapshot["filesCopied"] == 3
- assert full_snapshot["bytesCopied"] == 8
- assert full_snapshot["pushPercent"] == 100
- # 目标目录布局:manifest / 数据脚本 / env / 存储文件 / 索引
- full_dir = engine.backup_root / full_id
- assert (full_dir / "manifest.json").is_file()
- assert (full_dir / "database" / "dms-data.sql").is_file()
- assert (full_dir / "env" / ".env").is_file()
- assert (full_dir / "storage" / "original" / "a.pdf").is_file()
- assert (full_dir / "storage" / "original" / "sub" / "b.docx").is_file()
- assert (full_dir / "storage" / "preview" / "c.jpg").is_file()
- assert (engine.backup_root / "index.json").is_file()
- full_manifest = json.loads(
- (full_dir / "manifest.json").read_text(encoding="utf-8")
- )
- assert full_manifest["type"] == "FULL"
- assert full_manifest["parent"] is None
- assert set(full_manifest["files"]) == {
- "original/a.pdf",
- "original/sub/b.docx",
- "preview/c.jpg",
- }
- assert full_manifest["transferred_files"] == 3
- # 增量:新增一个文件
- _write_storage_file(engine, "original/new.txt", b"NEW-DATA")
- engine.start_async("INCREMENTAL", "SCHEDULE")
- incremental_snapshot = _wait_finished(engine)
- incremental_id = incremental_snapshot["backupId"]
- assert incremental_id.endswith("-I")
- assert incremental_snapshot["phase"] == "SUCCESS"
- assert incremental_snapshot["filesCopied"] == 1
- assert incremental_snapshot["trigger"] == "SCHEDULE"
- incremental_manifest = json.loads(
- (engine.backup_root / incremental_id / "manifest.json").read_text(
- encoding="utf-8"
- )
- )
- assert incremental_manifest["parent"] == full_id
- # 增量目录仅落差异文件
- assert (
- engine.backup_root / incremental_id / "storage" / "original" / "new.txt"
- ).is_file()
- assert not (
- engine.backup_root / incremental_id / "storage" / "original" / "a.pdf"
- ).exists()
- # 增量 manifest.files 为链尾累计清单
- assert set(incremental_manifest["files"]) == {
- "original/a.pdf",
- "original/sub/b.docx",
- "preview/c.jpg",
- "original/new.txt",
- }
- # 历史与定时类型决策:当日已有全量 → 距今未超间隔 → INCREMENTAL
- config = engine.load_config()
- history = engine.list_history(config)
- assert [entry["id"] for entry in history] == [full_id, incremental_id]
- assert history[1]["parent"] == full_id
- assert history[1]["storage_file_count"] == 4
- assert engine.decide_scheduled_type(config) == "INCREMENTAL"
- # 距最近全量超过间隔 → FULL(用三天前的全量独占索引,避免与今日真实全量混淆)
- config.full_interval_days = 1
- old_full_id = "dms-bak-" + time.strftime(
- "%Y%m%d", time.localtime(time.time() - 3 * 86400)
- ) + "-F"
- (engine.backup_root / "index.json").write_text(
- json.dumps([{"id": old_full_id, "type": "FULL"}]), encoding="utf-8"
- )
- assert engine.decide_scheduled_type(config) == "FULL"
- def test_engine_incremental_without_history_upgrades_to_full(engine_env):
- engine = engine_env
- _write_storage_file(engine, "original/only.txt", b"X")
- engine.start_async("INCREMENTAL", "MANUAL")
- snapshot = _wait_finished(engine)
- assert snapshot["phase"] == "SUCCESS"
- assert snapshot["type"] == "FULL"
- assert snapshot["backupId"].endswith("-F")
- def test_engine_fails_when_database_export_fails(engine_env, monkeypatch):
- from dms.common.errors import BackupFailedError
- from dms.services import sql_dump
- def failing_export(database_url, output_directory, schema):
- raise BackupFailedError("数据库导出失败(测试模拟)")
- monkeypatch.setattr(sql_dump, "export_dump", failing_export)
- engine = engine_env
- _write_storage_file(engine, "original/a.pdf", b"AAA")
- engine.start_async("FULL", "MANUAL")
- snapshot = _wait_finished(engine)
- assert snapshot["phase"] == "FAILED"
- assert "数据库导出失败" in snapshot["error"]
- # 失败备份不进历史(目标端无 manifest 即不可见),暂存目录已清理
- assert engine.list_history(engine.load_config()) == []
- assert not (engine.storage_root / "backup-staging").exists()
- def test_engine_completion_callback_success_and_failure(engine_env, monkeypatch):
- """终态回调(BACKUP_CREATE 审计结果来源):恰调一次、先于 running 复位。"""
- from dms.common.errors import BackupFailedError
- from dms.services import sql_dump
- engine = engine_env
- _write_storage_file(engine, "original/a.pdf", b"AAA")
- # 成功:outcome 携带最终类型/触发方式/备份ID,回调内任务仍视为运行中
- outcomes = []
- running_inside = []
- engine.start_async(
- "FULL",
- "MANUAL",
- on_complete=lambda outcome: (
- outcomes.append(outcome),
- running_inside.append(engine.is_running),
- ),
- )
- snapshot = _wait_finished(engine)
- assert snapshot["phase"] == "SUCCESS"
- assert len(outcomes) == 1
- success = outcomes[0]
- assert success.success is True
- assert success.type == "FULL"
- assert success.trigger == "MANUAL"
- assert success.backup_id == snapshot["backupId"]
- assert success.error is None
- assert running_inside == [True]
- # 失败:outcome 携带失败原因与备份ID(导出失败发生于备份目录建立后)
- def failing_export(database_url, output_directory, schema):
- raise BackupFailedError("数据库导出失败(测试模拟)")
- monkeypatch.setattr(sql_dump, "export_dump", failing_export)
- failed_outcomes = []
- engine.start_async(
- "INCREMENTAL", "SCHEDULE", on_complete=failed_outcomes.append
- )
- failed_snapshot = _wait_finished(engine)
- assert failed_snapshot["phase"] == "FAILED"
- assert len(failed_outcomes) == 1
- failure = failed_outcomes[0]
- assert failure.success is False
- assert failure.type == "INCREMENTAL"
- assert failure.trigger == "SCHEDULE"
- assert failure.backup_id == failed_snapshot["backupId"]
- assert "数据库导出失败" in failure.error
- # 早期失败(目标不可用,备份目录建立前):backup_id 为 None
- config_path = engine.backup_config_path
- from dms.services.backup_service import BackupConfig
- blocking = engine.storage_root / "not-a-directory"
- blocking.write_text("x", encoding="utf-8")
- BackupConfig(local_path=str(blocking)).save(config_path)
- early_outcomes = []
- engine.start_async("FULL", "MANUAL", on_complete=early_outcomes.append)
- _wait_finished(engine)
- assert len(early_outcomes) == 1
- assert early_outcomes[0].success is False
- assert early_outcomes[0].backup_id is None
- assert early_outcomes[0].error
- def test_engine_running_mutually_exclusive(engine_env):
- engine = engine_env
- from dms.common.errors import BackupRunningError
- engine._running.set()
- with pytest.raises(BackupRunningError):
- engine.start_async("FULL", "MANUAL")
- engine._running.clear()
- # 取消空闲任务为空操作,不抛异常
- engine.cancel()
- def test_retention_cleanup_removes_oldest_chains_and_residue(engine_env):
- from dms.services.backup_service import LocalBackupTarget
- target = LocalBackupTarget(engine_env.backup_root.parent / "retention-root")
- ids = {
- "full1": "dms-bak-20260101-000000-F",
- "inc1": "dms-bak-20260102-000000-I",
- "full2": "dms-bak-20260108-000000-F",
- "inc2": "dms-bak-20260109-000000-I",
- "full3": "dms-bak-20260115-000000-F",
- "residue": "dms-bak-20260105-000000-F",
- }
- for backup_id in ids.values():
- directory = target.root / backup_id
- directory.mkdir(parents=True)
- (directory / "manifest.json").write_text("{}", encoding="utf-8")
- index = [
- {"id": ids["full1"], "type": "FULL"},
- {"id": ids["inc1"], "type": "INCREMENTAL"},
- {"id": ids["full2"], "type": "FULL"},
- {"id": ids["inc2"], "type": "INCREMENTAL"},
- {"id": ids["full3"], "type": "FULL"},
- ]
- engine_env._cleanup_retention_policy(target, index, retain_full_count=2)
- # 最旧链(full1+inc1)与残留目录被清理,剩余两条链
- assert not (target.root / ids["full1"]).exists()
- assert not (target.root / ids["inc1"]).exists()
- assert not (target.root / ids["residue"]).exists()
- assert [entry["id"] for entry in index] == [
- ids["full2"],
- ids["inc2"],
- ids["full3"],
- ]
- # 按备份 ID 字典序(= 时间序)
- assert sorted(target.list_backup_directories()) == [
- ids["full2"],
- ids["inc2"],
- ids["full3"],
- ]
- def test_backup_config_json_roundtrip_and_validation(tmp_path: Path):
- from dms.common.errors import BackupConfigInvalidError
- from dms.services.backup_service import BackupConfig
- config = BackupConfig(
- local_path=str(tmp_path / "backup-root"),
- rsync_password="secret-value",
- schedule_enabled=True,
- )
- config.save(tmp_path / "dms-backup-config.json")
- # 磁盘 JSON 为 snake_case 全字段(含明文密码,双后端共享)
- disk = json.loads(
- (tmp_path / "dms-backup-config.json").read_text(encoding="utf-8")
- )
- assert disk == {
- "target_mode": "LOCAL",
- "local_path": str(tmp_path / "backup-root"),
- "rsync_url": "",
- "rsync_password": "secret-value",
- "schedule_enabled": True,
- "schedule_time": "02:00",
- "full_interval_days": 7,
- "retain_full_count": 2,
- }
- loaded = BackupConfig.load(tmp_path / "dms-backup-config.json")
- assert loaded.rsync_password == "secret-value"
- assert loaded.schedule_enabled is True
- # 回显视图永不回明文密码
- view = loaded.public_view()
- assert "rsync_password" not in view
- assert view["rsync_password_set"] is True
- # 损坏文件回退默认配置
- (tmp_path / "broken.json").write_text("{", encoding="utf-8")
- assert BackupConfig.load(tmp_path / "broken.json") == BackupConfig()
- assert BackupConfig.load(tmp_path / "missing.json") == BackupConfig()
- # 校验:模式/路径/时刻/间隔/保留数
- with pytest.raises(BackupConfigInvalidError):
- BackupConfig(target_mode="FTP", local_path="x").validate()
- with pytest.raises(BackupConfigInvalidError):
- BackupConfig().validate() # LOCAL 缺 local_path
- rsync = BackupConfig(target_mode="RSYNC", rsync_url="ftp://bad/x")
- with pytest.raises(BackupConfigInvalidError):
- rsync.validate()
- ok_rsync = BackupConfig(target_mode="RSYNC", rsync_url="rsync://u@h:873/mod/")
- ok_rsync.validate()
- with pytest.raises(BackupConfigInvalidError):
- BackupConfig(local_path="x", schedule_time="24:99").validate()
- with pytest.raises(BackupConfigInvalidError):
- BackupConfig(local_path="x", full_interval_days=0).validate()
- with pytest.raises(BackupConfigInvalidError):
- BackupConfig(local_path="x", retain_full_count=0).validate()
- # ================================================================ 接口级(B2 门控)
- def test_backup_endpoints_require_admin(b2_client, backup_env, token_for):
- missing = b2_client.get(CONFIG_URL)
- invalid = b2_client.get(
- CONFIG_URL, headers={"Authorization": "Bearer invalid-token"}
- )
- assert missing.status_code == 401
- assert missing.get_json()["code"] == "TOKEN_INVALID"
- assert invalid.status_code == 401
- assert invalid.get_json()["code"] == "TOKEN_INVALID"
- user_headers = _auth(token_for("user"))
- checks = (
- ("get", CONFIG_URL, None),
- ("put", CONFIG_URL, {}),
- ("post", JOBS_URL, {"type": "FULL"}),
- ("get", CURRENT_URL, None),
- ("post", CANCEL_URL, None),
- ("get", JOBS_URL, None),
- )
- for method, url, body in checks:
- response = getattr(b2_client, method)(
- url, json=body, headers=user_headers
- )
- assert response.status_code == 403
- assert response.get_json()["code"] == "FORBIDDEN"
- def test_config_default_view_and_roundtrip_with_password_masking(
- b2_app, b2_client, backup_env, login_token
- ):
- engine = backup_env
- response = b2_client.get(CONFIG_URL, headers=_auth(login_token))
- assert response.status_code == 200
- default = response.get_json()["data"]
- assert default == {
- "target_mode": "LOCAL",
- "local_path": "",
- "rsync_url": "",
- "rsync_password_set": False,
- "schedule_enabled": False,
- "schedule_time": "02:00",
- "full_interval_days": 7,
- "retain_full_count": 2,
- }
- saved = _save_local_config(
- b2_client,
- login_token,
- b2_app.config["BACKUP_TEST_ROOT"],
- rsync_password="secret-value",
- )
- assert saved["rsync_password_set"] is True
- assert "rsync_password" not in saved
- assert saved["local_path"] == str(b2_app.config["BACKUP_TEST_ROOT"])
- # 空密码 = 保持不变
- _save_local_config(b2_client, login_token, b2_app.config["BACKUP_TEST_ROOT"])
- disk = json.loads(
- engine.backup_config_path.read_text(encoding="utf-8")
- )
- assert disk["rsync_password"] == "secret-value"
- def test_config_validation_rejects_invalid_payload(b2_client, backup_env, login_token):
- headers = _auth(login_token)
- incomplete = b2_client.put(
- CONFIG_URL,
- json={"target_mode": "LOCAL"},
- headers=headers,
- )
- assert incomplete.status_code == 400
- assert incomplete.get_json()["code"] == "INVALID_ARGUMENT"
- base = {
- "target_mode": "LOCAL",
- "local_path": str(backup_env.backup_config_path.parent / "backup-root"),
- "rsync_url": None,
- "rsync_password": None,
- "schedule_enabled": False,
- "schedule_time": "02:00",
- "full_interval_days": 7,
- "retain_full_count": 2,
- }
- for field, value in (
- ("target_mode", "FTP"),
- ("schedule_time", "24:99"),
- ("full_interval_days", 0),
- ("retain_full_count", 0),
- ):
- payload = dict(base, **{field: value})
- response = b2_client.put(CONFIG_URL, json=payload, headers=headers)
- assert response.status_code == 400
- assert response.get_json()["code"] == "BACKUP_CONFIG_INVALID"
- def test_api_full_and_incremental_with_history_and_audit(
- b2_app, b2_client, backup_env, login_token
- ):
- from dms.extensions import db
- from dms.models import AuditLog
- engine = backup_env
- backup_root: Path = b2_app.config["BACKUP_TEST_ROOT"]
- _save_local_config(b2_client, login_token, backup_root)
- _write_storage_file(engine, "original/a.pdf", b"AAA")
- # 触发:202 受理 + 初始快照(12 业务字段)
- started = b2_client.post(
- JOBS_URL, json={"type": "FULL"}, headers=_auth(login_token)
- )
- assert started.status_code == 202
- assert set(started.get_json()["data"]) == PROGRESS_KEYS
- full_snapshot = _wait_finished(engine)
- full_id = full_snapshot["backupId"]
- assert full_snapshot["phase"] == "SUCCESS"
- assert full_id.endswith("-F")
- # 当前进度:12 业务字段 + running
- current = b2_client.get(CURRENT_URL, headers=_auth(login_token))
- assert current.status_code == 200
- current_data = current.get_json()["data"]
- assert set(current_data) == PROGRESS_KEYS | {"running"}
- assert current_data["running"] is False
- assert current_data["backupId"] == full_id
- # 历史:字段形状与链尾累计口径
- history = b2_client.get(JOBS_URL, headers=_auth(login_token))
- assert history.status_code == 200
- entries = history.get_json()["data"]["entries"]
- assert [entry["id"] for entry in entries] == [full_id]
- assert set(entries[0]) == HISTORY_ENTRY_KEYS
- assert entries[0]["storage_file_count"] == 1
- # 触发审计:记一条 BACKUP_CREATE(任务终态落库,成功)
- with b2_app.app_context():
- rows = db.session.execute(
- db.select(AuditLog).where(AuditLog.action_type == "BACKUP_CREATE")
- ).scalars().all()
- assert len(rows) == 1
- assert rows[0].target_type == "BACKUP"
- assert rows[0].operation_result == "SUCCESS"
- assert rows[0].failure_reason is None
- assert rows[0].target_name == "FULL"
- assert rows[0].operation_detail["backupId"] == full_id
- def test_api_failed_backup_audit_records_failure(
- b2_app, b2_client, backup_env, login_token, monkeypatch
- ):
- """备份任务失败:审计落 FAILURE 且携带失败原因(修复“失败显示成功”)。"""
- from dms.common.errors import BackupFailedError
- from dms.extensions import db
- from dms.models import AuditLog
- from dms.services import sql_dump
- def failing_export(database_url, output_directory, schema):
- raise BackupFailedError("数据库导出失败(测试模拟)")
- monkeypatch.setattr(sql_dump, "export_dump", failing_export)
- engine = backup_env
- _save_local_config(b2_client, login_token, b2_app.config["BACKUP_TEST_ROOT"])
- _write_storage_file(engine, "original/a.pdf", b"AAA")
- started = b2_client.post(
- JOBS_URL, json={"type": "FULL"}, headers=_auth(login_token)
- )
- assert started.status_code == 202
- snapshot = _wait_finished(engine)
- assert snapshot["phase"] == "FAILED"
- with b2_app.app_context():
- rows = db.session.execute(
- db.select(AuditLog).where(AuditLog.action_type == "BACKUP_CREATE")
- ).scalars().all()
- assert len(rows) == 1
- row = rows[0]
- assert row.operation_result == "FAILURE"
- assert "数据库导出失败" in row.failure_reason
- assert row.target_name == "FULL"
- assert row.operation_detail["backupId"] == snapshot["backupId"]
- assert row.operation_detail["trigger"] == "MANUAL"
- def test_api_invalid_type_rejected(b2_client, backup_env, login_token):
- response = b2_client.post(
- JOBS_URL, json={"type": "PARTIAL"}, headers=_auth(login_token)
- )
- assert response.status_code == 400
- assert response.get_json()["code"] == "INVALID_ARGUMENT"
- def test_api_start_conflict_while_running(b2_client, backup_env, login_token):
- engine = backup_env
- engine._running.set()
- try:
- response = b2_client.post(
- JOBS_URL, json={"type": "FULL"}, headers=_auth(login_token)
- )
- assert response.status_code == 409
- assert response.get_json()["code"] == "BACKUP_RUNNING"
- finally:
- engine._running.clear()
- def test_api_cancel_without_running_job(b2_client, backup_env, login_token):
- response = b2_client.post(CANCEL_URL, headers=_auth(login_token))
- assert response.status_code == 200
- assert response.get_json()["data"] == {"canceled": False}
|