"""Tests for Workflow Engine — covers ACs 8-22.""" from __future__ import annotations import uuid from datetime import datetime, timezone, timedelta import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from httpx import AsyncClient from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory from app.models.audit import AuditLog from app.models.notification import Notification from app.services.workflow_service import ( create_workflow, list_workflows, get_workflow, update_workflow, delete_workflow, create_instance, list_instances, get_instance, advance_instance, cancel_instance, check_timeout, auto_reject_timeout, find_workflows_for_event, start_instance_for_event, ) # ─── Workflow CRUD (ACs 8-12) ─── VALID_STEPS = [ {"name": "Step 1", "type": "action", "config": {"action_type": "noop"}}, {"name": "Step 2", "type": "approval", "config": {"required_role": "admin"}}, {"name": "Step 3", "type": "notification", "config": {"title": "Done", "body": "Completed"}}, ] @pytest.mark.asyncio async def test_ac8_create_workflow(client: AsyncClient, db_session): """AC8: POST /api/v1/workflows with valid steps JSONB returns 201 + workflow definition.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( "/api/v1/workflows", json={ "name": "Test Workflow", "description": "A test workflow", "steps": VALID_STEPS, }, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["name"] == "Test Workflow" assert len(data["steps"]) == 3 assert data["is_active"] is True assert "id" in data @pytest.mark.asyncio async def test_ac9_list_workflows(client: AsyncClient, db_session): """AC9: GET /api/v1/workflows returns 200 + paginated list.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create a workflow first await client.post( "/api/v1/workflows", json={"name": "WF1", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) resp = await client.get("/api/v1/workflows") assert resp.status_code == 200 data = resp.json() assert "items" in data assert "total" in data assert data["total"] >= 1 assert len(data["items"]) >= 1 @pytest.mark.asyncio async def test_ac10_get_workflow_by_id(client: AsyncClient, db_session): """AC10: GET /api/v1/workflows/{id} returns 200 + workflow detail with steps.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Detail WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] resp = await client.get(f"/api/v1/workflows/{wf_id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == wf_id assert data["name"] == "Detail WF" assert len(data["steps"]) == 3 @pytest.mark.asyncio async def test_ac11_update_workflow(client: AsyncClient, db_session): """AC11: PATCH /api/v1/workflows/{id} returns 200, updated.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Original", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] resp = await client.patch( f"/api/v1/workflows/{wf_id}", json={"name": "Updated Name", "description": "Updated desc"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["name"] == "Updated Name" assert data["description"] == "Updated desc" @pytest.mark.asyncio async def test_ac12_delete_workflow(client: AsyncClient, db_session): """AC12: DELETE /api/v1/workflows/{id} returns 204.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "To Delete", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] resp = await client.delete(f"/api/v1/workflows/{wf_id}", headers=ORIGIN_HEADER) assert resp.status_code == 204 # Verify deleted get_resp = await client.get(f"/api/v1/workflows/{wf_id}") assert get_resp.status_code == 404 # ─── Instance Lifecycle (ACs 13-18) ─── @pytest.mark.asyncio async def test_ac13_create_instance(client: AsyncClient, db_session): """AC13: POST /api/v1/workflows/{id}/instances returns 201, instance created with status=pending.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Instance WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={"context": {"key": "value"}}, headers=ORIGIN_HEADER, ) assert resp.status_code == 201 data = resp.json() assert data["status"] == "pending" assert data["current_step_index"] == 0 assert data["workflow_id"] == wf_id @pytest.mark.asyncio async def test_ac14_list_instances_filtered(client: AsyncClient, db_session): """AC14: GET /api/v1/workflows/instances?status=in_progress returns 200 + filtered list.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") # Create workflow and instance create_resp = await client.post( "/api/v1/workflows", json={"name": "Filter WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) # Filter by status=pending (just created) resp = await client.get("/api/v1/workflows/instances?status=pending") assert resp.status_code == 200 data = resp.json() assert all(item["status"] == "pending" for item in data["items"]) assert data["total"] >= 1 @pytest.mark.asyncio async def test_ac15_get_instance_detail(client: AsyncClient, db_session): """AC15: GET /api/v1/workflows/instances/{id} returns 200 + current_step_index + history.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Detail Instance WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] resp = await client.get(f"/api/v1/workflows/instances/{inst_id}") assert resp.status_code == 200 data = resp.json() assert data["id"] == inst_id assert "current_step_index" in data assert "history" in data assert len(data["history"]) >= 1 # At least the initial "entered" entry @pytest.mark.asyncio async def test_ac16_advance_approve(client: AsyncClient, db_session): """AC16: POST /api/v1/workflows/instances/{id}/advance (approve) returns 200, step advanced.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Advance WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] # Advance (approve) — should move from step 0 to step 1 resp = await client.post( f"/api/v1/workflows/instances/{inst_id}/advance", json={"decision": "approve"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["current_step_index"] == 1 assert data["status"] in ("in_progress",) @pytest.mark.asyncio async def test_ac17_advance_reject(client: AsyncClient, db_session): """AC17: POST /api/v1/workflows/instances/{id}/advance (reject) returns 200, status=rejected, initiator notified.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Reject WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] # Reject resp = await client.post( f"/api/v1/workflows/instances/{inst_id}/advance", json={"decision": "reject", "comment": "Not approved"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "rejected" assert data["completed_at"] is not None # Check notification was created for initiator result = await db_session.execute(select(Notification)) notifications = result.scalars().all() assert len(notifications) >= 1 assert any(n.type == "workflow_rejected" for n in notifications) @pytest.mark.asyncio async def test_ac18_cancel_instance(client: AsyncClient, db_session): """AC18: POST /api/v1/workflows/instances/{id}/cancel returns 200, status=cancelled.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Cancel WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] resp = await client.post( f"/api/v1/workflows/instances/{inst_id}/cancel", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "cancelled" assert data["completed_at"] is not None # ─── Event-Triggered & Code-Engine (ACs 19-22) ─── @pytest.mark.asyncio async def test_ac19_event_triggered_workflow(db_session): """AC19: Event-triggered workflow — publish event → workflow instance auto-starts.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create a workflow with trigger_event wf = await create_workflow( db_session, tenant_id, admin_id, { "name": "Event Triggered WF", "trigger_event": "company.created", "steps": VALID_STEPS, }, ) # Simulate event by calling start_instance_for_event instances = await start_instance_for_event( db_session, tenant_id, admin_id, "company.created", context={"company_id": "test"}, ) assert len(instances) >= 1 assert instances[0]["status"] == "pending" @pytest.mark.asyncio async def test_ac20_step_history_created(db_session): """AC20: workflow_step_history entry created on every step transition.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow( db_session, tenant_id, admin_id, {"name": "History WF", "steps": VALID_STEPS}, ) wf_id = wf["id"] inst = await create_instance(db_session, tenant_id, admin_id, wf_id) inst_id = inst["id"] # Advance to create more history entries await advance_instance(db_session, tenant_id, admin_id, inst_id, "approve") # Check step history result = await db_session.execute( select(WorkflowStepHistory).where( WorkflowStepHistory.instance_id == uuid.UUID(inst_id) ) ) history = result.scalars().all() assert len(history) >= 3 # entered + approved + entered (next step) @pytest.mark.asyncio async def test_ac21_onboarding_workflow_on_user_creation(db_session): """AC21: Code-engine workflow — onboarding workflow runs on user creation.""" from app.workflows.code.onboarding import trigger_onboarding, get_onboarding_workflow_definition seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id new_user_id = seed["viewer_a"].id # Simulate existing user as "new" # Trigger onboarding instance = await trigger_onboarding(db_session, tenant_id, admin_id, new_user_id) assert instance is not None assert instance["status"] == "pending" assert instance["context"]["new_user_id"] == str(new_user_id) # Verify workflow was created with correct steps result = await db_session.execute( select(Workflow).where(Workflow.trigger_event == "user.created") ) workflows = result.scalars().all() assert len(workflows) >= 1 assert workflows[0].name == "User Onboarding" assert len(workflows[0].steps) == 3 @pytest.mark.asyncio async def test_ac22_approval_timeout_auto_reject(db_session): """AC22: Approval step timeout → auto-reject after configured hours (tested with mock timer).""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create workflow with approval step wf = await create_workflow( db_session, tenant_id, admin_id, { "name": "Timeout WF", "steps": [ {"name": "Approval Step", "type": "approval", "config": {}}, ], }, ) # Create instance with timeout_hours=1 inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], timeout_hours=1, ) inst_id = uuid.UUID(inst["id"]) # Manually set timeout_at to past to simulate timeout result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == inst_id) ) instance = result.scalar_one() instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1) await db_session.flush() # Check timeout detection is_timed_out = await check_timeout(instance) assert is_timed_out is True # Auto-reject reject_result = await auto_reject_timeout(db_session, tenant_id, instance) assert reject_result["status"] == "rejected" assert reject_result["completed_at"] is not None # Check notification created result = await db_session.execute(select(Notification)) notifications = result.scalars().all() assert any(n.type == "workflow_timeout" for n in notifications) # ─── Edge Cases ─── @pytest.mark.asyncio async def test_workflow_not_found(client: AsyncClient, db_session): """Edge case: Get non-existent workflow returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get(f"/api/v1/workflows/{uuid.uuid4()}") assert resp.status_code == 404 @pytest.mark.asyncio async def test_instance_not_found(client: AsyncClient, db_session): """Edge case: Get non-existent instance returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get(f"/api/v1/workflows/instances/{uuid.uuid4()}") assert resp.status_code == 404 @pytest.mark.asyncio async def test_cancel_completed_instance_fails(client: AsyncClient, db_session): """Edge case: Cancelling a completed instance returns 400.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Complete WF", "steps": [{"name": "Only step", "type": "action", "config": {"action_type": "noop"}}]}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] # Advance to complete (single step) await client.post( f"/api/v1/workflows/instances/{inst_id}/advance", json={"decision": "approve"}, headers=ORIGIN_HEADER, ) # Try to cancel completed cancel_resp = await client.post( f"/api/v1/workflows/instances/{inst_id}/cancel", headers=ORIGIN_HEADER, ) assert cancel_resp.status_code == 400 @pytest.mark.asyncio async def test_workflow_tenant_isolation(client: AsyncClient, db_session): """Edge case: Workflow from tenant A not visible to tenant B.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Tenant A WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] # Login as tenant B from httpx import ASGITransport, AsyncClient as AC import app.main app_instance = app.main.app async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as client_b: await login_client(client_b, "admin@tenantb.com") resp = await client_b.get(f"/api/v1/workflows/{wf_id}") assert resp.status_code == 404 @pytest.mark.asyncio async def test_workflow_create_rbac_viewer_blocked(client: AsyncClient, db_session): """Edge case: Viewer cannot create workflows (403).""" await seed_tenant_and_users(db_session) await login_client(client, "viewer@tenanta.com") resp = await client.post( "/api/v1/workflows", json={"name": "Viewer WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 # ─── WorkflowEngine Unit Tests (engine.py direct coverage) ─── @pytest.mark.asyncio async def test_engine_process_action_step_noop(db_session): """Engine: action step with noop config advances to next step.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Engine Noop WF", "steps": [ {"name": "Action", "type": "action", "config": {"action_type": "noop"}}, {"name": "Approval", "type": "approval", "config": {}}, ], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) # Fetch the instance object result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 assert result_dict["status"] == "in_progress" @pytest.mark.asyncio async def test_engine_process_action_step_create_notification(db_session): """Engine: action step with create_notification creates a Notification.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Engine Notif WF", "steps": [ { "name": "Create Notif", "type": "action", "config": { "action_type": "create_notification", "user_id": str(admin_id), "title": "Test Notif", "body": "Hello from engine", "notification_type": "workflow_action", }, }, ], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) await engine.process_step(instance) # Check notification created notif_result = await db_session.execute(select(Notification)) notifications = notif_result.scalars().all() assert len(notifications) >= 1 assert any(n.title == "Test Notif" for n in notifications) @pytest.mark.asyncio async def test_engine_process_action_completes_last_step(db_session): """Engine: action step on last step completes the instance.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Single Step WF", "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["status"] == "completed" assert result_dict["completed_at"] is not None @pytest.mark.asyncio async def test_engine_process_approval_step(db_session): """Engine: approval step pauses and sets status to in_progress.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Approval WF", "steps": [{"name": "Approval", "type": "approval", "config": {}}], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() assert instance.status == "pending" engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["status"] == "in_progress" @pytest.mark.asyncio async def test_engine_process_notification_step(db_session): """Engine: notification step sends notification and advances.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Notif Step WF", "steps": [ { "name": "Notify", "type": "notification", "config": { "user_id": str(admin_id), "title": "Step Notif", "body": "You have a notification", "notification_type": "wf_notif", }, }, ], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["status"] == "completed" notif_result = await db_session.execute(select(Notification)) notifications = notif_result.scalars().all() assert any(n.title == "Step Notif" for n in notifications) @pytest.mark.asyncio async def test_engine_process_condition_eq_true(db_session): """Engine: condition step with eq operator, condition met, branches to on_true_step.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond Eq WF", "steps": [ { "name": "Check Status", "type": "condition", "config": { "field": "status", "operator": "eq", "value": "active", "on_true_step": 2, "on_false_step": 1, }, }, {"name": "False Step", "type": "action", "config": {"action_type": "noop"}}, {"name": "True Step", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"status": "active"}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 2 # on_true_step @pytest.mark.asyncio async def test_engine_process_condition_eq_false(db_session): """Engine: condition step with eq operator, condition not met, branches to on_false_step.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond Eq False WF", "steps": [ { "name": "Check Status", "type": "condition", "config": { "field": "status", "operator": "eq", "value": "active", "on_true_step": 2, "on_false_step": 1, }, }, {"name": "False Step", "type": "action", "config": {"action_type": "noop"}}, {"name": "True Step", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"status": "inactive"}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 # on_false_step @pytest.mark.asyncio async def test_engine_process_condition_ne(db_session): """Engine: condition step with ne (not equal) operator.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond NE WF", "steps": [ { "name": "Check", "type": "condition", "config": { "field": "level", "operator": "ne", "value": "low", }, }, {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"level": "high"}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 # condition met, no branch → advance @pytest.mark.asyncio async def test_engine_process_condition_gt(db_session): """Engine: condition step with gt (greater than) operator.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond GT WF", "steps": [ { "name": "Check Count", "type": "condition", "config": {"field": "count", "operator": "gt", "value": 5}, }, {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"count": 10}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 # 10 > 5, no branch → advance @pytest.mark.asyncio async def test_engine_process_condition_lt(db_session): """Engine: condition step with lt (less than) operator.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond LT WF", "steps": [ { "name": "Check Count", "type": "condition", "config": {"field": "count", "operator": "lt", "value": 100}, }, {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"count": 50}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 # 50 < 100, no branch → advance @pytest.mark.asyncio async def test_engine_process_condition_contains(db_session): """Engine: condition step with contains operator on string.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond Contains WF", "steps": [ { "name": "Check Tags", "type": "condition", "config": {"field": "tags", "operator": "contains", "value": "vip"}, }, {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"tags": "vip,premium"}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 # contains vip, no branch → advance @pytest.mark.asyncio async def test_engine_process_condition_contains_list(db_session): """Engine: condition step with contains operator on list.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond List WF", "steps": [ { "name": "Check List", "type": "condition", "config": {"field": "roles", "operator": "contains", "value": "admin"}, }, {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"roles": ["admin", "editor"]}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 @pytest.mark.asyncio async def test_engine_process_condition_no_match_advances(db_session): """Engine: condition not met and no on_false_step → advances to next step.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond No Branch WF", "steps": [ { "name": "Check", "type": "condition", "config": {"field": "status", "operator": "eq", "value": "active"}, }, {"name": "Next", "type": "action", "config": {"action_type": "noop"}}, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"status": "inactive"}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["current_step_index"] == 1 # no branch, advance @pytest.mark.asyncio async def test_engine_process_condition_completes_last_step(db_session): """Engine: condition on last step with no branch → completes.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Cond Last WF", "steps": [ { "name": "Check", "type": "condition", "config": {"field": "status", "operator": "eq", "value": "active"}, }, ], }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], context={"status": "active"}, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["status"] == "completed" @pytest.mark.asyncio async def test_engine_unknown_step_type(db_session): """Engine: unknown step type returns error with status_code 400.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Unknown Type WF", "steps": [{"name": "Bad", "type": "unknown_type", "config": {}}], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert "error" in result_dict assert result_dict["status_code"] == 400 @pytest.mark.asyncio async def test_engine_workflow_not_found(db_session): """Engine: process_step with non-existent workflow returns 404 error.""" from unittest.mock import AsyncMock, MagicMock, patch from app.workflows.engine import WorkflowEngine from app.models.workflow import WorkflowInstance seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create a real workflow and instance wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Temp WF for Not Found Test", "steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() # Mock the db.execute to return None for the workflow lookup original_execute = db_session.execute async def mock_execute(stmt, *args, **kwargs): # Check if this is a Workflow select query stmt_str = str(stmt) if "FROM workflows" in stmt_str or "workflow" in stmt_str.lower(): mock_result = MagicMock() mock_result.scalar_one_or_none = MagicMock(return_value=None) return mock_result return await original_execute(stmt, *args, **kwargs) engine = WorkflowEngine(db_session, tenant_id) with patch.object(db_session, "execute", side_effect=mock_execute): result_dict = await engine.process_step(instance) assert "error" in result_dict assert result_dict["status_code"] == 404 @pytest.mark.asyncio async def test_engine_step_index_beyond_steps_completes(db_session): """Engine: current_step_index >= len(steps) marks instance as completed.""" from app.workflows.engine import WorkflowEngine seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Beyond Steps WF", "steps": [{"name": "Step", "type": "action", "config": {"action_type": "noop"}}], }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() instance.current_step_index = 5 # Beyond steps length await db_session.flush() engine = WorkflowEngine(db_session, tenant_id) result_dict = await engine.process_step(instance) assert result_dict["status"] == "completed" @pytest.mark.asyncio async def test_engine_handle_event(db_session): """Engine: handle_event creates instances for matching workflows.""" from app.workflows.engine import handle_event seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Event Handler WF", "trigger_event": "company.created", "steps": VALID_STEPS, }) instances = await handle_event( db_session, tenant_id, "company.created", {"user_id": str(admin_id), "company_id": "test"}, ) assert len(instances) >= 1 assert instances[0]["status"] == "pending" @pytest.mark.asyncio async def test_engine_handle_event_no_match(db_session): """Engine: handle_event returns empty list when no workflows match.""" from app.workflows.engine import handle_event seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id instances = await handle_event( db_session, tenant_id, "nonexistent.event", {"user_id": str(seed["admin_a"].id)}, ) assert instances == [] def test_engine_register_workflow_event_handlers(): """Engine: register_workflow_event_handlers subscribes to event bus.""" from collections import defaultdict from app.workflows.engine import register_workflow_event_handlers from app.core.event_bus import get_event_bus event_bus = get_event_bus() # Reset handlers to a fresh defaultdict for test isolation event_bus._handlers = defaultdict(list) register_workflow_event_handlers() assert "user.created" in event_bus._handlers assert "company.created" in event_bus._handlers assert "contact.created" in event_bus._handlers assert len(event_bus._handlers["user.created"]) >= 1 # ─── Route Error Path Tests ─── @pytest.mark.asyncio async def test_route_update_workflow_not_found(client: AsyncClient, db_session): """Route: PATCH non-existent workflow returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.patch( f"/api/v1/workflows/{uuid.uuid4()}", json={"name": "Updated"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_route_update_workflow_rbac_blocked(client: AsyncClient, db_session): """Route: PATCH workflow as viewer returns 403.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "RBAC Update WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] # Login as viewer from httpx import ASGITransport, AsyncClient as AC import app.main app_instance = app.main.app async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as viewer_client: await login_client(viewer_client, "viewer@tenanta.com") resp = await viewer_client.patch( f"/api/v1/workflows/{wf_id}", json={"name": "Viewer Updated"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 403 @pytest.mark.asyncio async def test_route_delete_workflow_not_found(client: AsyncClient, db_session): """Route: DELETE non-existent workflow returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.delete(f"/api/v1/workflows/{uuid.uuid4()}", headers=ORIGIN_HEADER) assert resp.status_code == 404 @pytest.mark.asyncio async def test_route_delete_workflow_rbac_blocked(client: AsyncClient, db_session): """Route: DELETE workflow as viewer returns 403.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "RBAC Delete WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] from httpx import ASGITransport, AsyncClient as AC import app.main app_instance = app.main.app async with AC(transport=ASGITransport(app=app_instance), base_url="http://test") as viewer_client: await login_client(viewer_client, "viewer@tenanta.com") resp = await viewer_client.delete(f"/api/v1/workflows/{wf_id}", headers=ORIGIN_HEADER) assert resp.status_code == 403 @pytest.mark.asyncio async def test_route_create_instance_workflow_not_found(client: AsyncClient, db_session): """Route: POST instance for non-existent workflow returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( f"/api/v1/workflows/{uuid.uuid4()}/instances", json={"context": {}}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_route_advance_instance_not_found(client: AsyncClient, db_session): """Route: POST advance for non-existent instance returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( f"/api/v1/workflows/instances/{uuid.uuid4()}/advance", json={"decision": "approve"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_route_cancel_instance_not_found(client: AsyncClient, db_session): """Route: POST cancel for non-existent instance returns 404.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.post( f"/api/v1/workflows/instances/{uuid.uuid4()}/cancel", headers=ORIGIN_HEADER, ) assert resp.status_code == 404 @pytest.mark.asyncio async def test_route_advance_completed_instance_returns_400(client: AsyncClient, db_session): """Route: POST advance on completed instance returns 400.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Single Step", "steps": [{"name": "Only", "type": "action", "config": {"action_type": "noop"}}]}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] # Advance to complete await client.post( f"/api/v1/workflows/instances/{inst_id}/advance", json={"decision": "approve"}, headers=ORIGIN_HEADER, ) # Try to advance again — should get 400 resp = await client.post( f"/api/v1/workflows/instances/{inst_id}/advance", json={"decision": "approve"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 400 @pytest.mark.asyncio async def test_route_cancel_rejected_instance_returns_400(client: AsyncClient, db_session): """Route: POST cancel on rejected instance returns 400.""" await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") create_resp = await client.post( "/api/v1/workflows", json={"name": "Reject Cancel WF", "steps": VALID_STEPS}, headers=ORIGIN_HEADER, ) wf_id = create_resp.json()["id"] inst_resp = await client.post( f"/api/v1/workflows/{wf_id}/instances", json={}, headers=ORIGIN_HEADER, ) inst_id = inst_resp.json()["id"] # Reject first await client.post( f"/api/v1/workflows/instances/{inst_id}/advance", json={"decision": "reject"}, headers=ORIGIN_HEADER, ) # Try to cancel rejected instance resp = await client.post( f"/api/v1/workflows/instances/{inst_id}/cancel", headers=ORIGIN_HEADER, ) assert resp.status_code == 400 # ─── Service Edge Case Tests ─── @pytest.mark.asyncio async def test_service_check_timeout_no_timeout_at(db_session): """Service: check_timeout returns False when timeout_at is None.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "No Timeout WF", "steps": VALID_STEPS, }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"]) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() is_timed_out = await check_timeout(instance) assert is_timed_out is False @pytest.mark.asyncio async def test_service_check_timeout_completed_status(db_session): """Service: check_timeout returns False when status is completed.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Completed Timeout WF", "steps": VALID_STEPS, }) inst = await create_instance( db_session, tenant_id, admin_id, wf["id"], timeout_hours=1, ) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() instance.status = "completed" instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1) await db_session.flush() is_timed_out = await check_timeout(instance) assert is_timed_out is False @pytest.mark.asyncio async def test_service_auto_reject_no_initiated_by(db_session): """Service: auto_reject_timeout works when initiated_by is None.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "No Initiator WF", "steps": VALID_STEPS, }) inst = await create_instance(db_session, tenant_id, admin_id, wf["id"], timeout_hours=1) result = await db_session.execute( select(WorkflowInstance).where(WorkflowInstance.id == uuid.UUID(inst["id"])) ) instance = result.scalar_one() instance.initiated_by = None instance.timeout_at = datetime.now(timezone.utc) - timedelta(hours=1) await db_session.flush() reject_result = await auto_reject_timeout(db_session, tenant_id, instance) assert reject_result["status"] == "rejected" @pytest.mark.asyncio async def test_service_find_workflows_no_match(db_session): """Service: find_workflows_for_event returns empty list when no match.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id workflows = await find_workflows_for_event(db_session, tenant_id, "nonexistent.event") assert workflows == [] @pytest.mark.asyncio async def test_service_start_instance_no_match(db_session): """Service: start_instance_for_event returns empty list when no matching workflows.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id instances = await start_instance_for_event( db_session, tenant_id, admin_id, "nonexistent.event", context={}, ) assert instances == [] @pytest.mark.asyncio async def test_service_list_workflows_with_active_filter(db_session): """Service: list_workflows with is_active=True filter.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id # Create active workflow await create_workflow(db_session, tenant_id, admin_id, { "name": "Active WF", "steps": VALID_STEPS, "is_active": True, }) # Create inactive workflow await create_workflow(db_session, tenant_id, admin_id, { "name": "Inactive WF", "steps": VALID_STEPS, "is_active": False, }) result = await list_workflows(db_session, tenant_id, is_active=True) assert result["total"] == 1 assert result["items"][0]["name"] == "Active WF" @pytest.mark.asyncio async def test_service_update_workflow_with_steps(db_session): """Service: update_workflow with steps update replaces steps array.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id wf = await create_workflow(db_session, tenant_id, admin_id, { "name": "Steps Update WF", "steps": VALID_STEPS, }) new_steps = [{"name": "New Step", "type": "action", "config": {"action_type": "noop"}}] result = await update_workflow(db_session, tenant_id, admin_id, wf["id"], { "steps": new_steps, "is_active": False, }) assert result is not None assert len(result["steps"]) == 1 assert result["steps"][0]["name"] == "New Step" assert result["is_active"] is False @pytest.mark.asyncio async def test_service_update_workflow_not_found(db_session): """Service: update_workflow returns None for non-existent workflow.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await update_workflow(db_session, tenant_id, admin_id, str(uuid.uuid4()), {"name": "X"}) assert result is None @pytest.mark.asyncio async def test_service_delete_workflow_not_found(db_session): """Service: delete_workflow returns False for non-existent workflow.""" seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id admin_id = seed["admin_a"].id result = await delete_workflow(db_session, tenant_id, admin_id, str(uuid.uuid4())) assert result is False @pytest.mark.asyncio async def test_service_get_instance_not_found(db_session): """Service: get_instance returns None for non-existent instance.""" from app.services.workflow_service import get_instance seed = await seed_tenant_and_users(db_session) tenant_id = seed["tenant_a"].id result = await get_instance(db_session, tenant_id, str(uuid.uuid4())) assert result is None