mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +02:00
* refactor: carve out extraction panel * refactor: create spec versions for node types * refactor: create a GenericNode and remove custom nodes * feat: add python and typescript sdk * add dograh sdk * fix: fetch draft workflow definition over published one * fix: fix routes of SDKs to use code gen * chore: remove doclink dependency to reduce image size * chore: format files * chore: bump pipecat * feat: let mcp fetch archived workflows on demand * chore: fix tests * feat: add sdk documentation * chore: change banner and add badge
225 lines
7.9 KiB
Python
225 lines
7.9 KiB
Python
"""Integration tests for the `save_workflow` MCP tool.
|
|
|
|
Mocks `authenticate_mcp_request` and the db_client so tests don't need
|
|
a live DB, but exercises the real TS validator subprocess end-to-end —
|
|
parse is part of the contract the LLM relies on.
|
|
|
|
Round-trip and pure-parser tests live in `test_ts_bridge.py`; this file
|
|
focuses on the MCP tool's error-routing, version tagging, and DB-call
|
|
shape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
from dataclasses import dataclass
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
from api.mcp_server.tools.save_workflow import save_workflow
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
shutil.which("node") is None, reason="node binary not available"
|
|
)
|
|
|
|
|
|
# ─── Fixtures & helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class _FakeDraft:
|
|
version_number: int = 2
|
|
status: str = "draft"
|
|
|
|
|
|
class _FakeWorkflowModel:
|
|
id = 1
|
|
organization_id = 1
|
|
name = "test"
|
|
# reconcile_positions reads whichever of these holds the previous
|
|
# stored workflow JSON; None on all three is fine for a greenfield
|
|
# test and causes reconcile_positions to fall back to the placement
|
|
# heuristic for any new node.
|
|
current_definition = None
|
|
released_definition = None
|
|
workflow_definition = None
|
|
|
|
|
|
@pytest.fixture
|
|
def authed_user() -> MagicMock:
|
|
user = MagicMock()
|
|
user.selected_organization_id = 1
|
|
user.id = 1
|
|
return user
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_backends(authed_user: MagicMock):
|
|
save_mock = AsyncMock(return_value=_FakeDraft())
|
|
update_mock = AsyncMock(return_value=_FakeWorkflowModel())
|
|
with (
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.authenticate_mcp_request",
|
|
AsyncMock(return_value=authed_user),
|
|
),
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.db_client.get_workflow",
|
|
AsyncMock(return_value=_FakeWorkflowModel()),
|
|
),
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.db_client.save_workflow_draft",
|
|
save_mock,
|
|
),
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.db_client.update_workflow",
|
|
update_mock,
|
|
),
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.db_client.get_draft_version",
|
|
AsyncMock(return_value=None),
|
|
),
|
|
):
|
|
yield save_mock, update_mock
|
|
|
|
|
|
def _valid_code(name: str = "tool-test") -> str:
|
|
return f'''import {{ Workflow }} from "@dograh/sdk";
|
|
import {{ startCall, endCall }} from "@dograh/sdk/typed";
|
|
|
|
const wf = new Workflow({{ name: "{name}" }});
|
|
|
|
const greeting = wf.addTyped(startCall({{ name: "greeting", prompt: "Hi!" }}));
|
|
const done = wf.addTyped(endCall({{ name: "done", prompt: "Bye." }}));
|
|
|
|
wf.edge(greeting, done, {{ label: "done", condition: "conversation complete" }});
|
|
'''
|
|
|
|
|
|
# ─── Happy path ──────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path_saves_draft(mock_backends):
|
|
save_mock, update_mock = mock_backends
|
|
# Match the stored name so the rename branch stays dormant here.
|
|
result = await save_workflow(
|
|
workflow_id=1, code=_valid_code(name=_FakeWorkflowModel.name)
|
|
)
|
|
assert result["saved"] is True
|
|
assert result["workflow_id"] == 1
|
|
assert result["version_number"] == 2
|
|
assert result["status"] == "draft"
|
|
assert result["node_count"] == 2
|
|
assert result["edge_count"] == 1
|
|
assert result["renamed"] is False
|
|
assert result["name"] == _FakeWorkflowModel.name
|
|
save_mock.assert_awaited_once()
|
|
update_mock.assert_not_awaited()
|
|
payload = save_mock.call_args.kwargs["workflow_definition"]
|
|
assert len(payload["nodes"]) == 2
|
|
assert len(payload["edges"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rename_propagates_to_update_workflow(mock_backends):
|
|
save_mock, update_mock = mock_backends
|
|
result = await save_workflow(workflow_id=1, code=_valid_code(name="renamed"))
|
|
assert result["saved"] is True
|
|
assert result["renamed"] is True
|
|
assert result["name"] == "renamed"
|
|
update_mock.assert_awaited_once()
|
|
kwargs = update_mock.call_args.kwargs
|
|
assert kwargs["workflow_id"] == 1
|
|
assert kwargs["name"] == "renamed"
|
|
assert kwargs["workflow_definition"] is None
|
|
assert kwargs["organization_id"] == 1
|
|
save_mock.assert_awaited_once()
|
|
|
|
|
|
# ─── Parse-stage rejections ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parser_rejects_disallowed_top_level(mock_backends):
|
|
save_mock, update_mock = mock_backends
|
|
code = _valid_code() + "function evil() { return 1; }\n"
|
|
result = await save_workflow(workflow_id=1, code=code)
|
|
assert result["saved"] is False
|
|
assert result["error_code"] == "parse_error"
|
|
save_mock.assert_not_awaited()
|
|
update_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parser_rejects_unknown_factory(mock_backends):
|
|
save_mock, update_mock = mock_backends
|
|
code = """import { Workflow } from "@dograh/sdk";
|
|
const wf = new Workflow({ name: "x" });
|
|
const n = wf.addTyped(fakeNode({ name: "x", prompt: "y" }));
|
|
"""
|
|
result = await save_workflow(workflow_id=1, code=code)
|
|
assert result["saved"] is False
|
|
assert result["error_code"] == "parse_error"
|
|
assert "Unknown node type" in result["error"]
|
|
save_mock.assert_not_awaited()
|
|
update_mock.assert_not_awaited()
|
|
|
|
|
|
# ─── Validation-stage rejections ─────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_field_surfaces_validation_error(mock_backends):
|
|
save_mock, update_mock = mock_backends
|
|
code = """import { Workflow } from "@dograh/sdk";
|
|
import { startCall } from "@dograh/sdk/typed";
|
|
const wf = new Workflow({ name: "x" });
|
|
const n = wf.addTyped(startCall({ name: "g", prompt: "hi", promt: "typo" }));
|
|
"""
|
|
result = await save_workflow(workflow_id=1, code=code)
|
|
assert result["saved"] is False
|
|
assert result["error_code"] == "validation_error"
|
|
assert "Unknown field" in result["error"]
|
|
save_mock.assert_not_awaited()
|
|
update_mock.assert_not_awaited()
|
|
|
|
|
|
# ─── Graph-stage rejections ──────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_graph_validation_catches_missing_start_node(mock_backends):
|
|
save_mock, update_mock = mock_backends
|
|
# Only an end node — WorkflowGraph requires exactly one start node.
|
|
code = """import { Workflow } from "@dograh/sdk";
|
|
import { endCall } from "@dograh/sdk/typed";
|
|
const wf = new Workflow({ name: "orphan" });
|
|
const only = wf.addTyped(endCall({ name: "only", prompt: "bye" }));
|
|
"""
|
|
result = await save_workflow(workflow_id=1, code=code)
|
|
assert result["saved"] is False
|
|
assert result["error_code"] == "graph_validation"
|
|
save_mock.assert_not_awaited()
|
|
update_mock.assert_not_awaited()
|
|
|
|
|
|
# ─── Workflow not found / unauthorized ───────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_workflow_raises_404(authed_user: MagicMock):
|
|
with (
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.authenticate_mcp_request",
|
|
AsyncMock(return_value=authed_user),
|
|
),
|
|
patch(
|
|
"api.mcp_server.tools.save_workflow.db_client.get_workflow",
|
|
AsyncMock(return_value=None),
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
await save_workflow(workflow_id=999, code=_valid_code())
|
|
assert exc_info.value.status_code == 404
|