"""产物收尾回归:真实 SQLite、字幕文件和 API 下载验证暂停及故障恢复。""" import shutil from pathlib import Path import pytest from fastapi.testclient import TestClient from wov_app.db import Database from wov_app.main import app from wov_app.scheduler import WorkflowScheduler from wov_sdk.models import WorkflowDefinition @pytest.fixture def subtitle(tmp_path): """复用真实字幕资产,不在测试中生成模型输出或占位媒体。""" source = Path(__file__).resolve().parent.parent / "testdata/ocr_srt_run_ac7f480a3ccb.srt" if not source.is_file(): pytest.skip("缺少真实字幕资产") target = tmp_path / "original.srt" shutil.copy2(source, target) return target def _seed(db, source, finals): """登记已完成节点的真实产物,模拟节点执行结束后的断点。""" definition = {"name": "finalization", "version": 1, "nodes": [{"id": "step", "node_type": "echo"}], "final_outputs": finals} db.upsert_workflow({"id": "finalization", "name": "收尾回归"}) db.create_workflow_version("finalization", 1, definition) db.create_run({"id": "run_finalization", "workflow_id": "finalization", "workflow_version": 1, "status": "QUEUED", "input_uri": str(source), "created_at": "2026-01-01T00:00:00+00:00", "updated_at": "2026-01-01T00:00:00+00:00"}) db.create_artifact({"run_id": "run_finalization", "node_id": "step", "name": "step.file_uri", "uri": str(source)}) return WorkflowDefinition.from_dict(definition) def test_pause_during_final_registration_keeps_downloads(subtitle, tmp_path, monkeypatch): """在第一个最终别名登记后暂停,再继续,两别名均可下载且 URI 不漂移。""" db = Database(tmp_path / "test.db") _seed(db, subtitle, {"result": "step.file_uri", "copy": "step.file_uri"}) original = subtitle.read_bytes() create = db.create_artifact def pause_once(artifact): create(artifact) if artifact["name"] == "result": db.pause_run("run_finalization", "2026-01-01T00:00:00+00:00") scheduler = WorkflowScheduler(db, tmp_path / "storage") with monkeypatch.context() as patch: patch.setattr(db, "create_artifact", pause_once) scheduler.execute_run("run_finalization") first_uri = db.get_artifact("run_finalization", "result")["uri"] assert db.get_run("run_finalization")["status"] == "PAUSED" db.resume_run("run_finalization", "2026-01-01T00:00:00+00:00") scheduler.execute_run("run_finalization") assert db.get_run("run_finalization")["status"] == "COMPLETED" assert db.get_artifact("run_finalization", "result")["uri"] == first_uri with TestClient(app) as client: monkeypatch.setattr(app.state, "db", db) for alias in ("result", "copy", "step.file_uri"): response = client.get(f"/api/runs/run_finalization/artifacts/{alias}") assert response.status_code == 200 assert response.content == original def test_legacy_renamed_final_survives_resume(subtitle, tmp_path): """兼容旧版已改名但最终记录有效的断点,恢复不能覆盖成不存在的旧路径。""" db = Database(tmp_path / "test.db") _seed(db, subtitle, {"result": "step.file_uri"}) renamed = subtitle.with_name("legacy.srt") subtitle.rename(renamed) db.create_artifact({"run_id": "run_finalization", "node_id": "step", "name": "result", "uri": str(renamed)}) WorkflowScheduler(db, tmp_path / "storage").execute_run("run_finalization") assert db.get_run("run_finalization")["status"] == "COMPLETED" assert Path(db.get_artifact("run_finalization", "result")["uri"]).read_bytes() == renamed.read_bytes() def test_failed_final_copy_preserves_source_and_can_retry(subtitle, tmp_path, monkeypatch): """复制中途写入失败不登记残缺成品,源字幕可用,恢复后可成功收尾。""" db = Database(tmp_path / "test.db") _seed(db, subtitle, {"result": "step.file_uri"}) expected = subtitle.read_bytes() scheduler = WorkflowScheduler(db, tmp_path / "storage") def fail_copy(source, destination, **kwargs): # 文件 I/O 边界模拟磁盘写满:真实写出截断内容后报错。 Path(destination).write_bytes(Path(source).read_bytes()[:64]) raise OSError("disk full") with monkeypatch.context() as patch: patch.setattr(shutil, "copy2", fail_copy) scheduler.execute_run("run_finalization") assert db.get_run("run_finalization")["status"] == "FAILED" assert db.get_artifact("run_finalization", "result") is None assert subtitle.read_bytes() == expected assert not list((tmp_path / "storage").rglob("*.tmp")) db.update_run("run_finalization", status="QUEUED", updated_at="2026-01-01T00:00:00+00:00") scheduler.execute_run("run_finalization") assert db.get_run("run_finalization")["status"] == "COMPLETED" assert Path(db.get_artifact("run_finalization", "result")["uri"]).read_bytes() == expected def test_batch_copy_failure_preserves_old_product_and_workspace(subtitle, tmp_path, monkeypatch): """批量成品写到一半失败:旧字幕不变、run/工作空间保留,重试可收尾。""" from wov_app.batch import BatchWorker, create_job video_asset = Path(__file__).resolve().parent.parent / "testdata/subtitle_10s.mp4" if not video_asset.is_file(): pytest.skip("缺少真实视频资产") library = tmp_path / "library" library.mkdir() video = library / "movie.mp4" shutil.copy2(video_asset, video) db = Database(tmp_path / "test.db") _seed(db, subtitle, {"result": "step.file_uri"}) db.upsert_workflow({"id": "finalization", "name": "收尾回归", "published": 1, "latest_version": 1}) job_id = create_job(db, str(library), "finalization") item = db.list_batch_videos(job_id)[0] work_dir = Path(item["work_dir"]) work_dir.mkdir(parents=True) source = work_dir / "result.srt" shutil.copy2(subtitle, source) now = "2026-01-01T00:00:00+00:00" db.update_batch_video(item["id"], run_id="run_finalization", updated_at=now) db.update_run("run_finalization", status="COMPLETED", updated_at=now) db.create_artifact({"run_id": "run_finalization", "node_id": "step", "name": "result", "uri": str(source)}) target = library / "movie.CN.srt" shutil.copy2(subtitle, target) original = target.read_bytes() worker = BatchWorker(db) def fail_copy(source, destination, **kwargs): Path(destination).write_bytes(Path(source).read_bytes()[:64]) raise OSError("disk full") with monkeypatch.context() as patch: patch.setattr(shutil, "copy2", fail_copy) worker._process_job(db.get_batch_job(job_id)) assert db.get_batch_video(item["id"])["status"] == "FAILED" assert target.read_bytes() == original assert db.get_run("run_finalization") is not None assert source.read_bytes() == original assert not list(library.glob("*.tmp")) worker._process_job(db.get_batch_job(job_id)) assert db.get_batch_video(item["id"])["status"] == "COMPLETED" assert db.get_run("run_finalization") is None assert not work_dir.exists() assert target.read_bytes() == original assert video.read_bytes() == video_asset.read_bytes() @pytest.mark.parametrize("missing_ref", [False, True]) def test_missing_final_fails_run(subtitle, tmp_path, missing_ref): """必需最终输出无引用或文件丢失都应 FAILED,不能成功登记失效链接。""" db = Database(tmp_path / "test.db") _seed(db, subtitle, {"result": "step.missing" if missing_ref else "step.file_uri"}) if not missing_ref: subtitle.unlink() WorkflowScheduler(db, tmp_path / "storage").execute_run("run_finalization") run = db.get_run("run_finalization") assert run["status"] == "FAILED" assert "result" in run["error"]