"""src/wov_app/maintenance.py 的模块级测试(数据 → 测试过程 → 验证结果)。 被测模块:`src/wov_app/maintenance.py`(孤儿数据清理:只删明确死数据), 可独立调用。用例在临时目录构造真实存储布局与真实 SQLite 记录,验证 "该删的删、不该删的绝不删"(R01/R03 的安全约定)。 """ from __future__ import annotations from datetime import datetime, timedelta, timezone from pathlib import Path from wov_app.db import Database from wov_app.maintenance import OrphanCleaner def _now_iso(offset_seconds: int = 0) -> str: """返回当前 UTC 时间字符串,可偏移秒数(构造过期/未过期数据)。""" return (datetime.now(timezone.utc) + timedelta(seconds=offset_seconds)).isoformat() def _db_with_workflow(tmp_path: Path) -> Database: """建好工作流(任务表对 workflow_id 有外键约束)的临时库。""" db = Database(tmp_path / "wov.db") db.upsert_workflow({"id": "wf", "name": "流程", "description": ""}) db.create_workflow_version("wf", 1, {"nodes": []}) return db def _run(run_id: str, **overrides) -> dict: """构造真实任务记录。""" record = { "id": run_id, "workflow_id": "wf", "workflow_version": 1, "status": "COMPLETED", "current_node_id": None, "progress": 1.0, "error": None, "input_uri": None, "param_overrides": None, "source": "upload", "created_at": _now_iso(-7200), "updated_at": _now_iso(-7200), } record.update(overrides) return record def _cleaner(db: Database, storage: Path, grace_seconds: int = 3600) -> OrphanCleaner: """构造清理器(不启动后台线程,直接调用 clean_once)。""" return OrphanCleaner(db, storage, interval_seconds=999, grace_seconds=grace_seconds) # --------------------------------------------------------------------------- # 残留目录清理 # --------------------------------------------------------------------------- def test_removes_dangling_upload_and_run_dirs(tmp_path: Path) -> None: """无对应任务记录的 uploads/runs 残留目录被删除。""" # 数据:两个残留目录(库中无记录)。 storage = tmp_path / "storage" (storage / "uploads" / "ghost-1").mkdir(parents=True) (storage / "runs" / "ghost-2").mkdir(parents=True) (storage / "uploads" / "ghost-1" / "video.mp4").write_bytes(b"data") db = _db_with_workflow(tmp_path) # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果:两个目录都被清除。 assert removed == 2 assert not (storage / "uploads" / "ghost-1").exists() assert not (storage / "runs" / "ghost-2").exists() def test_keeps_dirs_with_task_records(tmp_path: Path) -> None: """有任务记录的目录不删(即使任务已完成)。""" # 数据:一个有效任务及其目录。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-1")) (storage / "runs" / "run-1").mkdir(parents=True) (storage / "runs" / "run-1" / "out.srt").write_text("字幕", encoding="utf-8") # 测试过程 _cleaner(db, storage).clean_once() # 验证结果:目录与任务记录都保留。 assert (storage / "runs" / "run-1" / "out.srt").is_file() assert db.get_run("run-1") is not None # --------------------------------------------------------------------------- # 任务记录清理 # --------------------------------------------------------------------------- def test_removes_expired_completed_run_without_files(tmp_path: Path) -> None: """COMPLETED、超过宽限期、产物文件全失的任务记录被删除。""" # 数据:过期完成任务,目录为空。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-1")) (storage / "runs" / "run-1").mkdir(parents=True) # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果 assert removed == 1 assert db.get_run("run-1") is None def test_keeps_failed_run(tmp_path: Path) -> None: """FAILED 任务绝不自动删除(用户可重试)。""" # 数据:过期失败任务,无产物文件。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-failed", status="FAILED", error="boom")) # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果 assert removed == 0 assert db.get_run("run-failed") is not None def test_keeps_running_and_queued_runs(tmp_path: Path) -> None: """QUEUED / RUNNING 任务不删(可能仍在执行或等待执行)。""" # 数据:排队中与运行中的任务。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-q", status="QUEUED")) db.create_run(_run("run-r", status="RUNNING")) # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果 assert removed == 0 assert db.get_run("run-q") is not None assert db.get_run("run-r") is not None def test_keeps_completed_run_within_grace_period(tmp_path: Path) -> None: """宽限期内的完成任务不删(下载可能还在进行)。""" # 数据:刚完成、无产物文件的任务。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-fresh", updated_at=_now_iso(-10))) # 测试过程 removed = _cleaner(db, storage, grace_seconds=3600).clean_once() # 验证结果 assert removed == 0 assert db.get_run("run-fresh") is not None def test_keeps_completed_run_with_existing_files(tmp_path: Path) -> None: """仍有产物文件的完成任务不删(下载仍可用)。""" # 数据:过期但产物仍存在的任务。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-1")) payload = storage / "runs" / "run-1" / "finals" / "out.srt" payload.parent.mkdir(parents=True) payload.write_text("字幕", encoding="utf-8") # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果 assert removed == 0 assert payload.is_file() def test_skips_batch_source_runs(tmp_path: Path) -> None: """source=batch 的运行跳过清理(工作空间在私有层级,用户媒体目录必须保留)。""" # 数据:过期完成的批量运行,产物不在主 runs 目录下。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-batch", source="batch")) # 用户媒体目录(若被误删会造成数据丢失)。 media_dir = tmp_path / "user-videos" media_dir.mkdir() (media_dir / "movie.mp4").write_bytes(b"video") # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果:批量 run 与用户媒体都保留。 assert removed == 0 assert db.get_run("run-batch") is not None assert (media_dir / "movie.mp4").is_file() def test_keeps_external_input_directory(tmp_path: Path) -> None: """删除孤儿任务时不动其 input_uri 指向的外部目录(R01:不误删媒体库)。""" # 数据:过期完成、无产物的任务,但 input_uri 指向用户媒体目录。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) media_dir = tmp_path / "media" media_dir.mkdir() video = media_dir / "movie.mp4" video.write_bytes(b"video") db.create_run(_run("run-1", input_uri=str(video))) # 测试过程 _cleaner(db, storage).clean_once() # 验证结果:任务记录被清理,但用户媒体目录完整保留。 assert db.get_run("run-1") is None assert video.is_file() def test_invalid_timestamp_is_kept(tmp_path: Path) -> None: """时间戳无法解析时保守保留(不因数据损坏误删)。""" # 数据:updated_at 非法。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) db.create_run(_run("run-1", updated_at="not-a-timestamp")) # 测试过程 removed = _cleaner(db, storage).clean_once() # 验证结果 assert removed == 0 assert db.get_run("run-1") is not None # --------------------------------------------------------------------------- # 线程生命周期 # --------------------------------------------------------------------------- def test_start_and_stop_are_idempotent(tmp_path: Path) -> None: """start 重复调用不产生多个线程;stop 能正常结束线程。""" # 数据:清理器(间隔很大,循环来不及真正清理)。 storage = tmp_path / "storage" db = _db_with_workflow(tmp_path) cleaner = OrphanCleaner(db, storage, interval_seconds=999, grace_seconds=3600) # 测试过程 cleaner.start() first_thread = cleaner._thread cleaner.start() second_thread = cleaner._thread cleaner.stop() # 验证结果:同一线程对象,停止后引用清空。 assert first_thread is second_thread assert cleaner._thread is None def test_stop_without_start_is_safe(tmp_path: Path) -> None: """未启动就 stop 不报错。""" # 数据:未启动的清理器。 db = _db_with_workflow(tmp_path) # 测试过程与验证结果:不抛异常。 OrphanCleaner(db, tmp_path / "storage").stop()