"""src/wov_app/routers/workflows.py 的模块级测试(数据 → 测试过程 → 验证结果)。 被测模块:`src/wov_app/routers/workflows.py`(管理端 API:工作流 CRUD、 校验、发布、版本历史),可独立调用。用例通过真实 TestClient 走 HTTP 链路, 数据库为临时 SQLite。 """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from wov_app.main import app as fastapi_app @pytest.fixture() def client(tmp_path: Path, monkeypatch) -> TestClient: """构造隔离数据库与存储的真实 TestClient。""" monkeypatch.setattr("wov_app.config.DB_PATH", tmp_path / "wov.db") monkeypatch.setattr("wov_app.config.STORAGE_DIR", tmp_path / "storage") monkeypatch.setattr("wov_app.main.DB_PATH", tmp_path / "wov.db") monkeypatch.setattr("wov_app.main.STORAGE_DIR", tmp_path / "storage") monkeypatch.setenv("WOV_AUTO_SEED", "0") monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "0") monkeypatch.setenv("WOV_CLEANUP_ENABLED", "0") monkeypatch.setenv("WOV_BATCH_ENABLED", "0") with TestClient(fastapi_app) as test_client: yield test_client def _definition(name: str = "echo-flow", nodes: list[dict] | None = None, edges: list[dict] | None = None) -> dict: """构造合法 DAG 定义(默认单 echo 节点)。""" return { "name": name, "version": 1, "nodes": nodes or [ {"id": "step", "node_type": "echo", "inputs": {"file_uri": "input.video_uri"}}, ], "edges": edges or [], "entry_inputs": {"video_uri": "file"}, "final_outputs": {"result": "step.file_uri"}, } def _create(client: TestClient, workflow_id: str = "wf", definition: dict | None = None) -> dict: """创建一个工作流并返回响应体。""" response = client.post("/api/admin/workflows", json={ "id": workflow_id, "name": "测试流程", "description": "说明", "definition": definition or _definition(), }) assert response.status_code == 200, response.text return response.json() # --------------------------------------------------------------------------- # 创建与读取 # --------------------------------------------------------------------------- def test_create_workflow_saves_first_version(client: TestClient) -> None: """创建工作流保存为 v1,初始未发布。""" # 数据:合法定义。 # 测试过程 created = _create(client, "wf-1") # 验证结果 assert created["id"] == "wf-1" assert created["latest_version"] == 1 assert created["published"] is False def test_create_workflow_appends_new_version(client: TestClient) -> None: """对已存在工作流再次创建 → 追加新版本(保存即新版本)。""" # 数据:同一 ID 两次创建。 _create(client, "wf-1") # 测试过程 second = _create(client, "wf-1") # 验证结果:版本递增。 assert second["latest_version"] == 2 def test_get_workflow_returns_latest_definition(client: TestClient) -> None: """详情返回最新版本定义(供编排页加载编辑)。""" # 数据:已创建工作流。 _create(client, "wf-1") # 测试过程 detail = client.get("/api/admin/workflows/wf-1") # 验证结果 assert detail.status_code == 200 body = detail.json() assert body["latest_version_data"]["version"] == 1 assert body["latest_version_data"]["definition"]["nodes"][0]["id"] == "step" def test_get_missing_workflow_returns_404(client: TestClient) -> None: """不存在的工作流返回 404。""" # 数据:未创建。 # 测试过程与验证结果 assert client.get("/api/admin/workflows/nope").status_code == 404 def test_list_workflows(client: TestClient) -> None: """列表返回全部工作流概要。""" # 数据:两个工作流。 _create(client, "wf-1") _create(client, "wf-2") # 测试过程 listed = client.get("/api/admin/workflows").json() # 验证结果 assert {item["id"] for item in listed} == {"wf-1", "wf-2"} # --------------------------------------------------------------------------- # 校验:拒绝非法 DAG # --------------------------------------------------------------------------- def test_create_rejects_cycle(client: TestClient) -> None: """环形 DAG 保存被拒(422),不产生工作流记录(R04)。""" # 数据:A→B→A 的环形定义。 cyclic = _definition(nodes=[ {"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}}, {"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}}, ], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}]) # 测试过程 response = client.post("/api/admin/workflows", json={ "id": "wf-cycle", "name": "环形", "description": "", "definition": cyclic, }) # 验证结果:422,且未写库。 assert response.status_code == 422 assert client.get("/api/admin/workflows/wf-cycle").status_code == 404 def test_create_rejects_self_loop(client: TestClient) -> None: """自环(节点指向自己)同样被拒绝。""" # 数据:单节点自环。 loop = _definition( nodes=[{"id": "a", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}}], edges=[{"from": "a", "to": "a"}], ) # 测试过程 response = client.post("/api/admin/workflows", json={ "id": "wf-loop", "name": "自环", "description": "", "definition": loop, }) # 验证结果 assert response.status_code == 422 def test_create_rejects_edge_to_unknown_node(client: TestClient) -> None: """边引用不存在的节点被拒绝。""" # 数据:边指向 ghost 节点。 bad = _definition(edges=[{"from": "step", "to": "ghost"}]) # 测试过程 response = client.post("/api/admin/workflows", json={ "id": "wf-bad", "name": "坏边", "description": "", "definition": bad, }) # 验证结果 assert response.status_code == 422 def test_create_rejects_duplicate_node_ids(client: TestClient) -> None: """节点 ID 重复被拒绝。""" # 数据:两个同 ID 节点。 dup = _definition(nodes=[ {"id": "step", "node_type": "echo", "inputs": {}}, {"id": "step", "node_type": "echo", "inputs": {}}, ]) # 测试过程 response = client.post("/api/admin/workflows", json={ "id": "wf-dup", "name": "重复", "description": "", "definition": dup, }) # 验证结果 assert response.status_code == 422 def test_validate_endpoint_returns_node_ids(client: TestClient) -> None: """校验接口对合法定义返回 valid 与节点 ID 列表(不保存)。""" # 数据:已创建工作流 + 待校验定义。 _create(client, "wf-1") # 测试过程 response = client.post("/api/admin/workflows/wf-1/validate", json=_definition()) # 验证结果 assert response.status_code == 200 assert response.json() == {"valid": True, "node_ids": ["step"]} def test_validate_endpoint_rejects_cycle(client: TestClient) -> None: """校验接口对环形定义返回 422。""" # 数据:环形定义。 _create(client, "wf-1") cyclic = _definition(nodes=[ {"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}}, {"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}}, ], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}]) # 测试过程与验证结果 assert client.post("/api/admin/workflows/wf-1/validate", json=cyclic).status_code == 422 # --------------------------------------------------------------------------- # 发布与版本 # --------------------------------------------------------------------------- def test_publish_marks_workflow_published(client: TestClient) -> None: """发布后工作流标记为已发布(出现在用户应用中心)。""" # 数据:已创建工作流。 _create(client, "wf-1") # 测试过程 response = client.post("/api/admin/workflows/wf-1/publish") detail = client.get("/api/admin/workflows/wf-1").json() # 验证结果 assert response.status_code == 200 assert detail["published"] == 1 def test_publish_rejects_legacy_cycle_version(client: TestClient) -> None: """历史遗留的环形版本无法发布(发布前重新校验,R04)。""" # 数据:绕过创建校验,直接写入环形版本(模拟历史数据)。 _create(client, "wf-1") db = client.app.state.db cyclic = _definition(nodes=[ {"id": "a", "node_type": "echo", "inputs": {"file_uri": "b.file_uri"}}, {"id": "b", "node_type": "echo", "inputs": {"file_uri": "a.file_uri"}}, ], edges=[{"from": "a", "to": "b"}, {"from": "b", "to": "a"}]) db.create_workflow_version("wf-1", 9, cyclic) # 测试过程 response = client.post("/api/admin/workflows/wf-1/publish") # 验证结果:被拒绝。 assert response.status_code == 422 def test_publish_rejects_workflow_without_version(client: TestClient) -> None: """无版本的工作流不能发布。""" # 数据:只有工作流记录,无版本(绕过创建接口)。 db = client.app.state.db db.upsert_workflow({"id": "empty", "name": "空", "description": "", "latest_version": 0}) # 测试过程 response = client.post("/api/admin/workflows/empty/publish") # 验证结果 assert response.status_code == 422 def test_list_versions_returns_history_desc(client: TestClient) -> None: """版本历史按版本号倒序返回,供对比与回滚。""" # 数据:两个版本。 _create(client, "wf-1") _create(client, "wf-1") # 测试过程 versions = client.get("/api/admin/workflows/wf-1/versions").json() # 验证结果 assert [item["version"] for item in versions] == [2, 1] def test_delete_workflow_removes_it(client: TestClient) -> None: """删除工作流后查询 404。""" # 数据:已创建工作流。 _create(client, "wf-1") # 测试过程 response = client.delete("/api/admin/workflows/wf-1") # 验证结果 assert response.status_code == 200 assert client.get("/api/admin/workflows/wf-1").status_code == 404 def test_delete_missing_workflow_returns_404(client: TestClient) -> None: """删除不存在的工作流返回 404。""" # 数据:未创建。 # 测试过程与验证结果 assert client.delete("/api/admin/workflows/nope").status_code == 404 def test_created_workflow_id_slugified_from_name_when_absent(client: TestClient) -> None: """未给 ID 时由名称生成 slug 形式 ID(管理端便利行为)。""" # 数据:只给名称。 # 测试过程 response = client.post("/api/admin/workflows", json={ "name": "My Cool Flow", "description": "", "definition": _definition(), }) # 验证结果 assert response.status_code == 200 assert response.json()["id"] == "my-cool-flow"