Files
deerflow-code/offline-backend-20260512/backend/tests/test_workflow_definitions.py
T
董宏凯 6d326a21cd feat(backend): 完善 Workflow 与 DeepResearch 的运行链路与持久化能力
功能说明:\n- 重构并增强工作流网关执行链路,支持更完整的 proposal 与 run 调度参数透传\n- 增加 DeepResearch/workflow 运行事件持久化能力并补齐建表 SQL 与查询/保存接口适配\n- 优化 lead agent 与 deep research runner 的中间态处理,增强线程收集与执行链并发控制\n- 调整工作流执行器与任务路由,提升兼容性并完善错误与回放行为\n- 补充回归测试:deep_research 持久化、workflow 并发、定义加载、引擎执行、run 流程等覆盖
2026-09-02 12:52:37 +08:00

258 lines
9.8 KiB
Python

"""Phase-1 workflow definition store + validator + Coze compat tests."""
from __future__ import annotations
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy.dialects import mysql
from sqlalchemy.schema import CreateTable
from app.gateway.routers import workflow_resources, workflows, workflows_coze_compat
from deerflow.persistence.workflows import (
MemoryWorkflowStore,
WorkflowDraftConflictError,
make_workflow_store,
)
from deerflow.persistence.workflows.model import WorkflowDefinitionRow
from deerflow.workflows.samples import load_sample_graph
from deerflow.workflows.validator import validate_workflow_graph
def test_workflow_definition_mysql_uses_longtext_without_a_server_default() -> None:
"""MySQL 5.7 rejects DEFAULT values on TEXT/LONGTEXT columns."""
ddl = str(CreateTable(WorkflowDefinitionRow.__table__).compile(dialect=mysql.dialect())).upper()
assert "DRAFT_GRAPH_JSON LONGTEXT NOT NULL" in ddl
assert "DRAFT_CANVAS_SCHEMA_JSON LONGTEXT NOT NULL" in ddl
assert "DRAFT_CANVAS_SCHEMA_JSON LONGTEXT NOT NULL DEFAULT" not in ddl
@pytest.mark.asyncio
async def test_memory_store_create_draft_publish() -> None:
store = make_workflow_store(None)
assert isinstance(store, MemoryWorkflowStore)
created = await store.create_definition(
{
"name": "专题报告",
"owner_id": "user_a",
"draft_graph": load_sample_graph("start_output").model_dump(by_alias=True),
}
)
assert created["draft_revision"] == 0
assert created["status"] == "draft"
wf_id = created["id"]
saved = await store.save_draft(
wf_id,
expected_revision=0,
graph=load_sample_graph("start_output").model_dump(by_alias=True),
)
assert saved["draft_revision"] == 1
with pytest.raises(WorkflowDraftConflictError) as exc:
await store.save_draft(
wf_id,
expected_revision=0,
graph=load_sample_graph("start_output").model_dump(by_alias=True),
)
assert exc.value.current_revision == 1
graph = load_sample_graph("start_output")
version = await store.publish_version(
wf_id,
graph=graph.model_dump(by_alias=True),
graph_hash=graph.graph_hash(),
published_by="user_a",
change_note="v1",
)
assert version["version_number"] == 1
assert version["graph_hash"] == graph.graph_hash()
definition = await store.get_definition(wf_id)
assert definition is not None
assert definition["status"] == "active"
versions = await store.list_versions(wf_id)
assert len(versions) == 1
loaded = await store.get_version(version["id"])
assert loaded is not None
assert loaded["graph"]["id"] == "wf_start_output"
def test_validate_start_output_and_report_samples() -> None:
start_output = load_sample_graph("start_output")
assert validate_workflow_graph(start_output) == []
report = load_sample_graph("report_generation")
issues = validate_workflow_graph(report)
assert issues == [], [i.model_dump(by_alias=True) for i in issues]
def test_validate_rejects_missing_output_and_cycle() -> None:
issues = validate_workflow_graph(
{
"schemaVersion": "1.0",
"id": "wf_bad",
"nodes": [
{"id": "start_1", "type": "start", "config": {}},
{"id": "a", "type": "transform", "config": {"operations": []}},
{"id": "b", "type": "transform", "config": {"operations": []}},
],
"edges": [
{"id": "e1", "source": "start_1", "target": "a"},
{"id": "e2", "source": "a", "target": "b"},
{"id": "e3", "source": "b", "target": "a"},
],
}
)
codes = {i.code for i in issues}
assert "WORKFLOW_SCHEMA_INVALID" in codes # missing output
assert "WORKFLOW_GRAPH_CYCLE" in codes
def test_workflow_http_api_and_coze_compat(monkeypatch: pytest.MonkeyPatch) -> None:
from deerflow.config.workflow_config import WorkflowConfig
class _Cfg:
workflows = WorkflowConfig()
models = []
monkeypatch.setattr(workflows, "get_app_config", lambda: _Cfg())
monkeypatch.setattr(workflow_resources, "get_app_config", lambda: _Cfg())
monkeypatch.setattr(workflows_coze_compat, "get_app_config", lambda: _Cfg())
app = FastAPI()
store = MemoryWorkflowStore()
app.state.workflow_store = store
app.include_router(workflow_resources.router)
app.include_router(workflows.router)
app.include_router(workflows_coze_compat.router)
app.include_router(workflows_coze_compat.chrome_router)
async def _fake_user(_request=None):
return "user_a"
monkeypatch.setattr(workflows, "get_current_user", _fake_user)
monkeypatch.setattr(workflows, "get_optional_user_from_request", lambda _r: None)
monkeypatch.setattr(workflow_resources, "get_current_user", _fake_user)
monkeypatch.setattr(workflows_coze_compat, "get_current_user", _fake_user)
client = TestClient(app)
created = client.post("/api/workflows", json={"name": "demo"}).json()
wf_id = created["id"]
assert created["draft_revision"] == 0
graph = load_sample_graph("start_output").model_dump(by_alias=True)
saved = client.put(
f"/api/workflows/{wf_id}/draft",
json={"expectedRevision": 0, "graph": graph},
).json()
assert saved["draft_revision"] == 1
conflict = client.put(
f"/api/workflows/{wf_id}/draft",
json={"expectedRevision": 0, "graph": graph},
)
assert conflict.status_code == 409
validated = client.post(f"/api/workflows/{wf_id}/validate", json={}).json()
assert validated["ok"] is True
published = client.post(
f"/api/workflows/{wf_id}/publish",
json={"changeNote": "first", "expectedRevision": 1},
).json()
assert published["version_number"] == 1
types = client.get("/api/workflows/node-types").json()
# The built-in catalog may grow while preserving the minimum v1 nodes.
assert len(types["nodeTypes"]) >= 13
canvas = client.post("/api/workflow_api/canvas", json={"workflowId": wf_id}).json()
assert canvas["code"] == 0
workflow_brief = canvas["data"]["workflow"]
assert workflow_brief["workflow_id"] == wf_id
assert workflow_brief["is_published"] is True
# Coze Playground uses this alongside VCS metadata to decide whether nodes
# are draggable. An owned standalone workflow must not open as preview.
assert workflow_brief["creator"]["self"] is True
# Never opened in the Coze editor → built-in start→end canvas fallback.
assert json.loads(workflow_brief["schema_json"])["nodes"]
coze_canvas = json.dumps(
{
"nodes": [
{"id": "100001", "type": "1", "meta": {"position": {"x": 0, "y": 0}}},
{"id": "900001", "type": "2", "meta": {"position": {"x": 400, "y": 0}}},
],
"edges": [{"sourceNodeID": "100001", "targetNodeID": "900001"}],
}
)
saved = client.post(
"/api/workflow_api/save",
json={"workflow_id": wf_id, "schema": coze_canvas},
)
assert saved.status_code == 200, saved.text
assert saved.json()["code"] == 0
reloaded = client.post("/api/workflow_api/canvas", json={"workflowId": wf_id}).json()
# Storage canonicalises to compact JSON; compare as documents.
assert json.loads(reloaded["data"]["workflow"]["schema_json"]) == json.loads(coze_canvas)
# The Coze save must round-trip the canvas without touching the graph.
definition = client.get(f"/api/workflows/{wf_id}").json()
assert definition["draft_graph"]["nodes"], "execution graph must pass through unchanged"
templates = client.post("/api/workflow_api/node_template_list").json()
assert templates["code"] == 0
template_list = templates["data"]["template_list"]
assert len(template_list) == 13
# Coze playground 形状:node_type 数字串 + 分类表
assert all("node_type" in item and "name" in item for item in template_list)
assert templates["data"]["cate_list"]
account = client.post("/api/passport/account/info/v2/").json()
assert account["code"] == 0
assert account["data"]["user_id"] == "user_a"
assert account["data"]["login_status"] == "logined"
playground_spaces = client.post("/api/playground_api/space/list").json()
assert playground_spaces["code"] == 0
assert playground_spaces["data"]["has_personal_space"] is True
assert playground_spaces["data"]["bot_space_list"][0]["id"] == "1"
listed = client.post("/api/space/list").json()
assert listed["code"] == 0
assert listed["bot_space_list"][0]["id"] == "1"
assert listed["data"]["bot_space_list"][0]["name"] == "默认空间"
assert listed["data"]["bot_space_list"][0]["space_type"] == 1
saved_space = client.post("/api/playground_api/space/save").json()
assert saved_space["code"] == 0
assert saved_space["data"]["id"] == "1"
models = client.post("/api/bot/get_type_list").json()
assert models["code"] == 0
assert models["data"]["model_list"] == []
copied = client.post(f"/api/workflows/{wf_id}/copy")
assert copied.status_code == 200, copied.text
assert copied.json()["id"] != wf_id
assert copied.json()["name"].endswith("副本")
def test_coze_studio_paths_are_csrf_exempt() -> None:
from app.gateway.csrf_middleware import is_coze_studio_compat_path
assert is_coze_studio_compat_path("/api/playground_api/space/list")
assert is_coze_studio_compat_path("/api/passport/account/info/v2/")
assert is_coze_studio_compat_path("/api/workflow_api/canvas")
assert is_coze_studio_compat_path("/api/space/list")
assert is_coze_studio_compat_path("/api/bot/get_type_list")
assert not is_coze_studio_compat_path("/api/workflows")
assert not is_coze_studio_compat_path("/api/v1/auth/me")