fischer-agentkit/tests/unit/server/test_workflow_routes.py

954 lines
32 KiB
Python

"""Tests for Workflow API routes"""
from __future__ import annotations
import asyncio
import pytest
from fastapi.testclient import TestClient
from agentkit.llm.gateway import LLMGateway
from agentkit.server.app import create_app
from agentkit.server.routes.workflows import WorkflowStore, router as workflow_router
from agentkit.skills.registry import SkillRegistry
from agentkit.tools.registry import ToolRegistry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_llm_gateway():
return LLMGateway()
@pytest.fixture
def skill_registry():
return SkillRegistry()
@pytest.fixture
def tool_registry():
return ToolRegistry()
@pytest.fixture
def app(mock_llm_gateway, skill_registry, tool_registry):
application = create_app(
llm_gateway=mock_llm_gateway,
skill_registry=skill_registry,
tool_registry=tool_registry,
)
# Register workflow routes (not yet in app.py)
application.include_router(workflow_router, prefix="/api/v1")
# Attach a fresh WorkflowStore to app state for isolation
application.state.workflow_store = WorkflowStore()
return application
@pytest.fixture
def client(app):
return TestClient(app)
def _create_workflow(client, name: str = "测试工作流", stages: list | None = None):
"""Helper to create a workflow via API."""
body: dict = {"name": name}
if stages is not None:
body["stages"] = stages
response = client.post("/api/v1/workflows", json=body)
return response
def _sample_stages():
"""Return a list of sample workflow stages."""
return [
{
"name": "开始",
"agent": "default",
"action": "start",
"depends_on": [],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "检查条件",
"agent": "default",
"action": "evaluate",
"depends_on": ["开始"],
"inputs": {},
"outputs": [],
"timeout_seconds": 60,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "condition",
"config": {"expression": "status == 'approved'"},
},
{
"name": "人工审批",
"agent": "default",
"action": "approve",
"depends_on": ["检查条件"],
"inputs": {},
"outputs": [],
"timeout_seconds": 3600,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "approval",
"config": {"approver": "admin", "timeout": 3600},
},
]
# ---------------------------------------------------------------------------
# WorkflowStore unit tests
# ---------------------------------------------------------------------------
class TestWorkflowStore:
def test_save_and_get(self):
from agentkit.orchestrator.workflow_schema import WorkflowDefinition
store = WorkflowStore()
wf = WorkflowDefinition(workflow_id="test-1", name="Test")
asyncio.run(store.save(wf))
result = store.get("test-1")
assert result is not None
assert result.name == "Test"
def test_get_not_found(self):
store = WorkflowStore()
assert store.get("nonexistent") is None
def test_list(self):
from agentkit.orchestrator.workflow_schema import WorkflowDefinition
async def _run():
store = WorkflowStore()
for i in range(3):
await store.save(WorkflowDefinition(workflow_id=f"wf-{i}", name=f"Workflow {i}"))
summaries = store.list()
assert len(summaries) == 3
asyncio.run(_run())
def test_list_limit(self):
from agentkit.orchestrator.workflow_schema import WorkflowDefinition
async def _run():
store = WorkflowStore()
for i in range(5):
await store.save(WorkflowDefinition(workflow_id=f"wf-{i}", name=f"Workflow {i}"))
summaries = store.list(limit=2)
assert len(summaries) == 2
asyncio.run(_run())
def test_delete(self):
from agentkit.orchestrator.workflow_schema import WorkflowDefinition
async def _run():
store = WorkflowStore()
await store.save(WorkflowDefinition(workflow_id="del-1", name="Delete Me"))
assert await store.delete("del-1") is True
assert store.get("del-1") is None
asyncio.run(_run())
def test_delete_not_found(self):
store = WorkflowStore()
assert asyncio.run(store.delete("nonexistent")) is False
def test_create_and_get_execution(self):
store = WorkflowStore()
execution = asyncio.run(store.create_execution("wf-1"))
assert execution.workflow_id == "wf-1"
assert execution.status == "pending"
fetched = store.get_execution(execution.execution_id)
assert fetched is not None
assert fetched.execution_id == execution.execution_id
def test_get_execution_not_found(self):
store = WorkflowStore()
assert store.get_execution("nonexistent") is None
def test_update_execution(self):
async def _run():
store = WorkflowStore()
execution = await store.create_execution("wf-1")
updated = await store.update_execution(
execution.execution_id, status="running", current_stage="step-1"
)
assert updated.status == "running"
assert updated.current_stage == "step-1"
asyncio.run(_run())
def test_update_execution_not_found(self):
store = WorkflowStore()
with pytest.raises(KeyError):
asyncio.run(store.update_execution("nonexistent", status="running"))
def test_running_tasks_initialized(self):
store = WorkflowStore()
assert hasattr(store, "_running_tasks")
assert isinstance(store._running_tasks, dict)
assert len(store._running_tasks) == 0
def test_execution_locks_initialized(self):
store = WorkflowStore()
assert hasattr(store, "_execution_locks")
assert isinstance(store._execution_locks, dict)
def test_evict_execution_cleans_approval_events(self):
async def _run():
store = WorkflowStore(max_executions=2)
e1 = await store.create_execution("wf-1")
e2 = await store.create_execution("wf-2")
# Add an approval event for e1
event_key = f"{e1.execution_id}:stage1"
event = asyncio.Event()
store._approval_events[event_key] = event
# Create a third execution to trigger eviction of e1
e3 = await store.create_execution("wf-3")
# e1 should be evicted, and its approval event should be cleaned up
assert store.get_execution(e1.execution_id) is None
assert event_key not in store._approval_events
# The event should have been set (to wake any waiting coroutine)
assert event.is_set()
asyncio.run(_run())
# ---------------------------------------------------------------------------
# POST /workflows - Create
# ---------------------------------------------------------------------------
class TestCreateWorkflow:
def test_create_empty_workflow(self, client):
response = _create_workflow(client)
assert response.status_code == 201
data = response.json()
assert data["name"] == "测试工作流"
assert data["workflow_id"] is not None
assert data["version"] == 1
def test_create_workflow_with_stages(self, client):
response = _create_workflow(client, stages=_sample_stages())
assert response.status_code == 201
data = response.json()
assert len(data["stages"]) == 3
assert data["stages"][0]["type"] == "skill"
assert data["stages"][1]["type"] == "condition"
assert data["stages"][2]["type"] == "approval"
def test_create_workflow_missing_dependency(self, client):
stages = [
{
"name": "步骤B",
"agent": "default",
"action": "do_b",
"depends_on": ["不存在的步骤"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
}
]
response = _create_workflow(client, stages=stages)
assert response.status_code == 400
assert "不存在" in response.json()["detail"]
def test_create_workflow_circular_dependency(self, client):
stages = [
{
"name": "A",
"agent": "default",
"action": "do_a",
"depends_on": ["B"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "B",
"agent": "default",
"action": "do_b",
"depends_on": ["A"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
]
response = _create_workflow(client, stages=stages)
assert response.status_code == 400
assert "循环依赖" in response.json()["detail"]
# ---------------------------------------------------------------------------
# GET /workflows - List
# ---------------------------------------------------------------------------
class TestListWorkflows:
def test_list_empty(self, client):
response = client.get("/api/v1/workflows")
assert response.status_code == 200
data = response.json()
assert data["workflows"] == []
assert data["total"] == 0
def test_list_after_create(self, client):
_create_workflow(client, "工作流1")
_create_workflow(client, "工作流2")
response = client.get("/api/v1/workflows")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
def test_list_with_limit(self, client):
for i in range(5):
_create_workflow(client, f"工作流{i}")
response = client.get("/api/v1/workflows?limit=2")
assert response.status_code == 200
data = response.json()
assert len(data["workflows"]) == 2
# ---------------------------------------------------------------------------
# GET /workflows/{id} - Get
# ---------------------------------------------------------------------------
class TestGetWorkflow:
def test_get_existing(self, client):
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
response = client.get(f"/api/v1/workflows/{workflow_id}")
assert response.status_code == 200
data = response.json()
assert data["workflow_id"] == workflow_id
assert len(data["stages"]) == 3
def test_get_not_found(self, client):
response = client.get("/api/v1/workflows/nonexistent-id")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# PUT /workflows/{id} - Update
# ---------------------------------------------------------------------------
class TestUpdateWorkflow:
def test_update_name(self, client):
create_resp = _create_workflow(client, "原始名称")
workflow_id = create_resp.json()["workflow_id"]
response = client.put(
f"/api/v1/workflows/{workflow_id}",
json={"name": "更新名称", "stages": []},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "更新名称"
assert data["version"] == 2
def test_update_with_stages(self, client):
create_resp = _create_workflow(client)
workflow_id = create_resp.json()["workflow_id"]
response = client.put(
f"/api/v1/workflows/{workflow_id}",
json={"name": "更新工作流", "stages": _sample_stages()},
)
assert response.status_code == 200
data = response.json()
assert len(data["stages"]) == 3
def test_update_not_found(self, client):
response = client.put(
"/api/v1/workflows/nonexistent-id",
json={"name": "不存在", "stages": []},
)
assert response.status_code == 404
def test_update_circular_dependency(self, client):
create_resp = _create_workflow(client)
workflow_id = create_resp.json()["workflow_id"]
stages = [
{
"name": "A",
"agent": "default",
"action": "do_a",
"depends_on": ["B"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "B",
"agent": "default",
"action": "do_b",
"depends_on": ["A"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
]
response = client.put(
f"/api/v1/workflows/{workflow_id}",
json={"name": "循环依赖", "stages": stages},
)
assert response.status_code == 400
# ---------------------------------------------------------------------------
# DELETE /workflows/{id} - Delete
# ---------------------------------------------------------------------------
class TestDeleteWorkflow:
def test_delete_existing(self, client):
create_resp = _create_workflow(client)
workflow_id = create_resp.json()["workflow_id"]
response = client.delete(f"/api/v1/workflows/{workflow_id}")
assert response.status_code == 200
# Verify deleted
get_resp = client.get(f"/api/v1/workflows/{workflow_id}")
assert get_resp.status_code == 404
def test_delete_not_found(self, client):
response = client.delete("/api/v1/workflows/nonexistent-id")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# POST /workflows/{id}/execute - Execute
# ---------------------------------------------------------------------------
class TestExecuteWorkflow:
def test_execute_workflow(self, client):
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
response = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {"status": "approved"}},
)
assert response.status_code == 200
data = response.json()
assert data["execution_id"] is not None
assert data["workflow_id"] == workflow_id
assert data["status"] in ("pending", "running")
def test_execute_not_found(self, client):
response = client.post(
"/api/v1/workflows/nonexistent-id/execute",
json={"variables": {}},
)
assert response.status_code == 404
def test_execute_empty_workflow(self, client):
create_resp = _create_workflow(client)
workflow_id = create_resp.json()["workflow_id"]
response = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
assert response.status_code == 200
# ---------------------------------------------------------------------------
# GET /workflows/executions/{id} - Execution status
# ---------------------------------------------------------------------------
class TestGetExecution:
def test_get_execution_status(self, client):
import time
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
exec_resp = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
execution_id = exec_resp.json()["execution_id"]
# Wait a bit for execution to progress
time.sleep(0.5)
response = client.get(f"/api/v1/workflows/executions/{execution_id}")
assert response.status_code == 200
data = response.json()
assert data["execution_id"] == execution_id
assert data["status"] in ("pending", "running", "completed", "paused")
def test_get_execution_not_found(self, client):
response = client.get("/api/v1/workflows/executions/nonexistent-id")
assert response.status_code == 404
# ---------------------------------------------------------------------------
# POST /workflows/executions/{id}/approve - Approval
# ---------------------------------------------------------------------------
class TestApproveExecution:
def test_approve_not_paused(self, client):
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
exec_resp = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
execution_id = exec_resp.json()["execution_id"]
# Try to approve when not paused (may already be completed)
response = client.post(
f"/api/v1/workflows/executions/{execution_id}/approve",
json={"approved": True, "comment": "同意"},
)
# Should be 400 if not paused, or 200 if already completed
assert response.status_code in (200, 400)
def test_approve_not_found(self, client):
response = client.post(
"/api/v1/workflows/executions/nonexistent-id/approve",
json={"approved": True},
)
assert response.status_code == 404
# ---------------------------------------------------------------------------
# POST /workflows/executions/{id}/cancel - Cancel
# ---------------------------------------------------------------------------
class TestCancelExecution:
def test_cancel_execution(self, client):
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
exec_resp = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
execution_id = exec_resp.json()["execution_id"]
# Try to cancel
response = client.post(
f"/api/v1/workflows/executions/{execution_id}/cancel"
)
# May be 200 (cancelled) or 400 (already completed)
assert response.status_code in (200, 400)
def test_cancel_not_found(self, client):
response = client.post(
"/api/v1/workflows/executions/nonexistent-id/cancel"
)
assert response.status_code == 404
def test_cancel_completed_execution(self, client):
import time
# Create a workflow with stages that will complete
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
exec_resp = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
execution_id = exec_resp.json()["execution_id"]
# Wait for completion
time.sleep(2)
# Check the execution status first
status_resp = client.get(f"/api/v1/workflows/executions/{execution_id}")
exec_status = status_resp.json()["status"]
# Try to cancel - should fail if already completed
response = client.post(
f"/api/v1/workflows/executions/{execution_id}/cancel"
)
if exec_status in ("completed", "failed"):
assert response.status_code == 400
else:
# If still running/paused, cancel should succeed
assert response.status_code == 200
# ---------------------------------------------------------------------------
# Validation tests
# ---------------------------------------------------------------------------
class TestValidation:
def test_missing_dependency_validation(self, client):
stages = [
{
"name": "步骤A",
"agent": "default",
"action": "do_a",
"depends_on": [],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "步骤B",
"agent": "default",
"action": "do_b",
"depends_on": ["步骤C"], # C doesn't exist
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
]
response = _create_workflow(client, stages=stages)
assert response.status_code == 400
assert "不存在" in response.json()["detail"]
def test_circular_dependency_validation(self, client):
stages = [
{
"name": "X",
"agent": "default",
"action": "do_x",
"depends_on": ["Y"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "Y",
"agent": "default",
"action": "do_y",
"depends_on": ["X"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
]
response = _create_workflow(client, stages=stages)
assert response.status_code == 400
assert "循环依赖" in response.json()["detail"]
def test_valid_linear_workflow(self, client):
stages = [
{
"name": "步骤1",
"agent": "default",
"action": "do_1",
"depends_on": [],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "步骤2",
"agent": "default",
"action": "do_2",
"depends_on": ["步骤1"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
]
response = _create_workflow(client, stages=stages)
assert response.status_code == 201
# ---------------------------------------------------------------------------
# GET /workflows/{id}/executions - Execution history
# ---------------------------------------------------------------------------
class TestListWorkflowExecutions:
def test_list_executions_empty(self, client):
"""Empty execution history returns empty list."""
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
response = client.get(f"/api/v1/workflows/{workflow_id}/executions")
assert response.status_code == 200
data = response.json()
assert data["executions"] == []
assert data["total"] == 0
def test_list_executions_after_execute(self, client):
"""Execution history shows executions after running a workflow."""
import time
create_resp = _create_workflow(client, stages=_sample_stages())
workflow_id = create_resp.json()["workflow_id"]
# Execute the workflow
exec_resp = client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
assert exec_resp.status_code == 200
# Wait a bit for execution to progress
time.sleep(0.5)
response = client.get(f"/api/v1/workflows/{workflow_id}/executions")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert len(data["executions"]) >= 1
exec_item = data["executions"][0]
assert "execution_id" in exec_item
assert exec_item["workflow_id"] == workflow_id
assert "status" in exec_item
assert "started_at" in exec_item
def test_list_executions_with_pagination(self, client):
"""Pagination works for execution history."""
create_resp = _create_workflow(client)
workflow_id = create_resp.json()["workflow_id"]
# Execute multiple times
for _ in range(3):
client.post(
f"/api/v1/workflows/{workflow_id}/execute",
json={"variables": {}},
)
# Limit to 2
response = client.get(f"/api/v1/workflows/{workflow_id}/executions?limit=2")
assert response.status_code == 200
data = response.json()
assert len(data["executions"]) <= 2
assert data["total"] >= 3
# Offset
response = client.get(f"/api/v1/workflows/{workflow_id}/executions?limit=2&offset=2")
assert response.status_code == 200
def test_list_executions_workflow_not_found(self, client):
"""404 for nonexistent workflow."""
response = client.get("/api/v1/workflows/nonexistent-id/executions")
assert response.status_code == 404
def test_list_executions_isolation(self, client):
"""Executions from one workflow don't appear in another."""
import time
create_resp1 = _create_workflow(client, "工作流1")
wf_id_1 = create_resp1.json()["workflow_id"]
create_resp2 = _create_workflow(client, "工作流2")
wf_id_2 = create_resp2.json()["workflow_id"]
# Execute only workflow 1
client.post(
f"/api/v1/workflows/{wf_id_1}/execute",
json={"variables": {}},
)
time.sleep(0.3)
# Workflow 2 should have no executions
response = client.get(f"/api/v1/workflows/{wf_id_2}/executions")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
# Workflow 1 should have at least 1
response = client.get(f"/api/v1/workflows/{wf_id_1}/executions")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
# ---------------------------------------------------------------------------
# WorkflowStore.list_executions unit tests
# ---------------------------------------------------------------------------
class TestWorkflowStoreListExecutions:
def test_list_executions_empty(self):
store = WorkflowStore()
executions, total = store.list_executions("wf-1")
assert executions == []
assert total == 0
def test_list_executions_with_data(self):
async def _run():
store = WorkflowStore()
await store.create_execution("wf-1")
await store.create_execution("wf-1")
await store.create_execution("wf-2")
executions, total = store.list_executions("wf-1")
assert total == 2
assert len(executions) == 2
asyncio.run(_run())
def test_list_executions_pagination(self):
async def _run():
store = WorkflowStore()
for _ in range(5):
await store.create_execution("wf-1")
executions, total = store.list_executions("wf-1", limit=2, offset=0)
assert total == 5
assert len(executions) == 2
executions2, _ = store.list_executions("wf-1", limit=2, offset=2)
assert len(executions2) == 2
asyncio.run(_run())
def test_list_executions_sorted_by_started_at_desc(self):
async def _run():
import time
store = WorkflowStore()
e1 = await store.create_execution("wf-1")
time.sleep(0.01)
e2 = await store.create_execution("wf-1")
executions, _ = store.list_executions("wf-1")
# Most recent first
assert executions[0].execution_id == e2.execution_id
assert executions[1].execution_id == e1.execution_id
asyncio.run(_run())
def test_valid_dag_workflow(self, client):
stages = [
{
"name": "开始",
"agent": "default",
"action": "start",
"depends_on": [],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "并行A",
"agent": "default",
"action": "do_a",
"depends_on": ["开始"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "并行B",
"agent": "default",
"action": "do_b",
"depends_on": ["开始"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
{
"name": "合并",
"agent": "default",
"action": "merge",
"depends_on": ["并行A", "并行B"],
"inputs": {},
"outputs": [],
"timeout_seconds": 300,
"retry_count": 0,
"continue_on_failure": False,
"condition": None,
"type": "skill",
"config": {},
},
]
response = _create_workflow(client, stages=stages)
assert response.status_code == 201