1569 lines
53 KiB
Python
1569 lines
53 KiB
Python
|
|
"""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
|