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