"""用户应用 API 测试。 覆盖已发布应用的上传建任务、进度查询、产物下载、失败重试以及 未发布/无版本工作流的拒绝逻辑。节点为内置注册,无需再手动注册。 """ from pathlib import Path from fastapi.testclient import TestClient from wov_app.main import app def _create_published_echo_workflow(client) -> str: """创建一个已发布的单节点 Echo 工作流(echo 为内置节点)。""" definition = { "name": "echo-flow", "version": 1, "nodes": [ { "id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}, } ], "edges": [], "entry_inputs": {"video_uri": "file"}, "final_outputs": {"result": "step.file_uri"}, } client.post( "/api/admin/workflows", json={ "id": "echo-app", "name": "Echo App", "description": "upload a file", "definition": definition, }, ) client.post("/api/admin/workflows/echo-app/publish") return "echo-app" def test_upload_run_progress_and_download() -> None: """验证上传文件建任务、手动执行、查询产物与下载的完整流程。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) apps = client.get("/api/apps") assert apps.status_code == 200 assert any(item["id"] == workflow_id for item in apps.json()) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"hello from upload", "text/plain")}, ) assert uploaded.status_code == 200 run_id = uploaded.json()["id"] assert uploaded.json()["status"] == "QUEUED" run = client.get(f"/api/runs/{run_id}") assert run.status_code == 200 assert run.json()["input_uri"].endswith("sample.txt") assert run.json()["artifacts"] == [] scheduler = app.state.scheduler scheduler.execute_run(run_id) completed = client.get(f"/api/runs/{run_id}") assert completed.status_code == 200 assert completed.json()["status"] == "COMPLETED" artifact_names = [item["name"] for item in completed.json()["artifacts"]] assert "result" in artifact_names artifacts = client.get(f"/api/runs/{run_id}/artifacts") assert artifacts.status_code == 200 assert len(artifacts.json()) >= 1 downloaded = client.get(f"/api/runs/{run_id}/artifacts/result") assert downloaded.status_code == 200 assert b"hello from upload" in downloaded.content assert client.get(f"/api/runs/{run_id}/artifacts/missing").status_code == 404 assert client.get("/api/runs/missing").status_code == 404 assert client.get("/api/runs/missing/artifacts").status_code == 404 db = app.state.db db.create_artifact( { "run_id": run_id, "node_id": "step", "name": "missing-file", "uri": str(Path(__file__).resolve().parent / "not-exists.bin"), "mime_type": "text/plain", "size": 0, } ) assert client.get(f"/api/runs/{run_id}/artifacts/missing-file").status_code == 404 runs = client.get("/api/runs") assert runs.status_code == 200 assert any(item["id"] == run_id for item in runs.json()) def test_upload_rejects_unpublished_workflow() -> None: """验证草稿或不存在的工作流不能被用户发起任务。""" with TestClient(app) as client: client.post( "/api/admin/workflows", json={ "id": "draft", "name": "Draft", "definition": { "name": "Draft", "version": 1, "nodes": [], "edges": [], }, }, ) response = client.post( "/api/apps/draft/runs", files={"file": ("x.txt", b"x", "text/plain")}, ) assert response.status_code == 404 response = client.post( "/api/apps/missing/runs", files={"file": ("x.txt", b"x", "text/plain")}, ) assert response.status_code == 404 def test_upload_rejects_workflow_without_version() -> None: """验证已发布但没有任何版本的工作流返回 422。""" with TestClient(app) as client: db = app.state.db db.upsert_workflow( {"id": "empty", "name": "Empty", "published": 1, "latest_version": 0} ) response = client.post( "/api/apps/empty/runs", files={"file": ("x.txt", b"x", "text/plain")}, ) assert response.status_code == 422 def test_retry_failed_run_requeues_and_reruns() -> None: """验证失败任务重试会清空旧产物并重新执行成功。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"hello retry", "text/plain")}, ) run_id = uploaded.json()["id"] db = app.state.db db.update_run( run_id, status="FAILED", error="boom", updated_at="2026-01-01T00:00:00+00:00", ) db.create_artifact( { "run_id": run_id, "node_id": "step", "name": "stale", "uri": "stale.txt", "mime_type": "text/plain", "size": 1, } ) response = client.post(f"/api/runs/{run_id}/retry") assert response.status_code == 200 assert response.json() == {"id": run_id, "status": "QUEUED"} run = client.get(f"/api/runs/{run_id}").json() assert run["status"] == "QUEUED" assert run["error"] is None assert run["artifacts"] == [] app.state.scheduler.execute_run(run_id) completed = client.get(f"/api/runs/{run_id}").json() assert completed["status"] == "COMPLETED" assert any(item["name"] == "result" for item in completed["artifacts"]) def test_pause_resume_run_api() -> None: """验证暂停/继续接口:QUEUED→PAUSED→QUEUED,状态非法时报 422。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"hello pause", "text/plain")}, ) run_id = uploaded.json()["id"] assert uploaded.json()["status"] == "QUEUED" from wov_app.config import STORAGE_DIR flag = STORAGE_DIR / "runs" / run_id / "paused.flag" paused = client.post(f"/api/runs/{run_id}/pause") assert paused.status_code == 200 assert paused.json() == {"id": run_id, "status": "PAUSED"} assert client.get(f"/api/runs/{run_id}").json()["status"] == "PAUSED" # 暂停时写入暂停信号文件,供运行中的节点(如 OCR)逐帧检查并中止。 assert flag.exists() resumed = client.post(f"/api/runs/{run_id}/resume") assert resumed.status_code == 200 assert resumed.json() == {"id": run_id, "status": "QUEUED"} assert client.get(f"/api/runs/{run_id}").json()["status"] == "QUEUED" # 继续时清除暂停信号,避免误触发节点内暂停。 assert not flag.exists() # 非 PAUSED 任务不可继续。 assert client.post(f"/api/runs/{run_id}/resume").status_code == 422 # 不存在的任务 404。 assert client.post("/api/runs/missing/pause").status_code == 404 assert client.post("/api/runs/missing/resume").status_code == 404 def test_pause_rejects_terminal_states() -> None: """验证已完成任务不可暂停。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"hello done", "text/plain")}, ) run_id = uploaded.json()["id"] db = app.state.db db.update_run(run_id, status="COMPLETED", progress=1.0, updated_at="2026-01-01T00:00:00+00:00") assert client.post(f"/api/runs/{run_id}/pause").status_code == 422 def test_retry_rejects_non_failed_and_missing_runs() -> None: """验证只有 FAILED 状态且存在的任务才能重试。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"x", "text/plain")}, ) run_id = uploaded.json()["id"] assert client.post(f"/api/runs/{run_id}/retry").status_code == 422 assert client.post("/api/runs/missing/retry").status_code == 404 def test_delete_run_removes_record_and_files() -> None: """验证删除任务会清理数据库记录与磁盘上的上传/步骤文件。""" import shutil from wov_app.config import STORAGE_DIR with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"hello delete", "text/plain")}, ) run_id = uploaded.json()["id"] # 执行任务以生成步骤产物目录。 app.state.scheduler.execute_run(run_id) run = client.get(f"/api/runs/{run_id}").json() steps_dir = STORAGE_DIR / "runs" / run_id assert steps_dir.is_dir() # 上传文件目录也应存在。 upload_dir = Path(run["input_uri"]).parent assert upload_dir.is_dir() deleted = client.delete(f"/api/runs/{run_id}") assert deleted.status_code == 200 assert deleted.json() == {"deleted": run_id} assert client.get(f"/api/runs/{run_id}").status_code == 404 assert not steps_dir.exists() assert not upload_dir.exists() # 删除不存在的任务返回 404。 assert client.delete(f"/api/runs/missing").status_code == 404 # 清理测试遗留的 runs 目录,避免跨用例残留。 shutil.rmtree(STORAGE_DIR / "runs", ignore_errors=True) def test_create_run_with_param_overrides() -> None: """验证创建任务时可携带 params 覆盖(如前端框选的 crop),并持久化。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) uploaded = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"x", "text/plain")}, data={"params": '{"step": {"crop": [0, 0.82, 1, 0.18]}}'}, ) assert uploaded.status_code == 200 run_id = uploaded.json()["id"] run = client.get(f"/api/runs/{run_id}").json() assert run["param_overrides"] == {"step": {"crop": [0, 0.82, 1, 0.18]}} # 非法 JSON 返回 422。 bad = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"x", "text/plain")}, data={"params": "not-json"}, ) assert bad.status_code == 422 def test_create_run_params_non_object_rejected() -> None: """params 为 JSON 数组时返回 422。""" with TestClient(app) as client: workflow_id = _create_published_echo_workflow(client) response = client.post( f"/api/apps/{workflow_id}/runs", files={"file": ("sample.txt", b"x", "text/plain")}, data={"params": "[1,2,3]"}, ) assert response.status_code == 422