"""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")