infrasynth-backend-kit/tests/test_workflows/test_engine.py
2026-08-28 14:38:47 -05:00

307 lines
15 KiB
Python

import pytest
from infrasynth.shared.enums import ApprovalStrategy
from infrasynth.workflows.engine import WorkflowEngine
from infrasynth.workflows.models import (
NodeAssignment,
Transition,
Workflow,
WorkflowInstance,
WorkflowNode,
WorkflowObserver,
)
pytestmark = pytest.mark.django_db
@pytest.fixture
def approval_workflow():
workflow = Workflow.objects.create(slug="approval", name="Approval", created_by=None)
start = WorkflowNode.objects.create(workflow=workflow, name="Start", node_type=WorkflowNode.NodeType.START)
review = WorkflowNode.objects.create(workflow=workflow, name="Review", node_type=WorkflowNode.NodeType.INTERMEDIATE)
end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END)
Transition.objects.create(from_node=start, to_node=review, condition_slug="approved")
Transition.objects.create(from_node=review, to_node=end, condition_slug="approved")
return workflow
class TestStart:
def test_starts_at_start_node(self, user, approval_workflow):
instance = WorkflowEngine().start("approval", owner=user)
assert instance.status == WorkflowInstance.Status.IN_PROGRESS
assert instance.current_node.name == "Start"
assert instance.owner_id == user.id
def test_unknown_workflow_raises(self, user):
with pytest.raises(ValueError, match="not found"):
WorkflowEngine().start("missing", owner=user)
def test_inactive_workflow_raises(self, user):
workflow = Workflow.objects.create(slug="inactive-wf", name="Inactive")
workflow.is_active = False
workflow.save(update_fields=["is_active"])
with pytest.raises(ValueError, match="inactive"):
WorkflowEngine().start("inactive-wf", owner=user)
def test_workflow_without_nodes_raises(self, user):
Workflow.objects.create(slug="no-nodes", name="No nodes")
with pytest.raises(ValueError, match="no nodes"):
WorkflowEngine().start("no-nodes", owner=user)
def test_emits_instance_started_signal(self, user, approval_workflow):
from infrasynth.workflows.signals import instance_started
sent = []
receiver = lambda **kw: sent.append(kw) # noqa: E731
instance_started.connect(receiver, weak=False)
instance = WorkflowEngine().start("approval", owner=user)
assert sent
assert sent[0]["instance"].id == instance.id
assert sent[0]["workflow_slug"] == "approval"
instance_started.disconnect(receiver)
class TestSubmitDecision:
def test_approve_advances_node(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
instance = engine.submit_decision(assignment.id, "approved", user=user)
assert instance.current_node.name == "Review"
assignment.refresh_from_db()
assert assignment.has_processed is True
assert assignment.decision == "approved"
def test_second_submission_rejected(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
engine.submit_decision(assignment.id, "approved", user=user)
with pytest.raises(ValueError, match="already been processed"):
engine.submit_decision(assignment.id, "approved", user=user)
def test_wrong_user_cannot_submit(self, user, approval_workflow, admin_user):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
with pytest.raises(PermissionError, match="not assigned"):
engine.submit_decision(assignment.id, "approved", user=admin_user)
def test_decision_without_transition_stays(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
instance = engine.submit_decision(assignment.id, "escalated", user=user)
assert instance.current_node.name == "Start"
assert instance.status == WorkflowInstance.Status.IN_PROGRESS
def test_completes_at_end_node(self, user, approval_workflow):
from infrasynth.workflows.signals import instance_completed
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
first = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
instance = engine.submit_decision(first.id, "approved", user=user)
second = NodeAssignment.objects.get(instance=instance, node__name="Review", user=user)
completed = []
receiver = lambda **kw: completed.append(kw) # noqa: E731
instance_completed.connect(receiver, weak=False)
instance = engine.submit_decision(second.id, "approved", user=user)
assert instance.status == WorkflowInstance.Status.COMPLETED
assert instance.completed_at is not None
assert instance.current_node.name == "End"
assert completed
assert completed[0]["workflow_slug"] == "approval"
instance_completed.disconnect(receiver)
def test_emits_decision_submitted_signal(self, user, approval_workflow):
from infrasynth.workflows.signals import decision_submitted
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
sent = []
receiver = lambda **kw: sent.append(kw) # noqa: E731
decision_submitted.connect(receiver, weak=False)
engine.submit_decision(assignment.id, "approved", user=user, comments="OK")
assert sent
assert sent[0]["decision"] == "approved"
assert sent[0]["user"] == user
decision_submitted.disconnect(receiver)
class TestApprovalStrategies:
def _setup(self, strategy, min_approvals=1):
workflow = Workflow.objects.create(slug="wf-strat", name="Strategy")
node = WorkflowNode.objects.create(
workflow=workflow,
name="Gate",
node_type=WorkflowNode.NodeType.START,
approval_strategy=strategy,
min_approvals=min_approvals,
)
end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END)
Transition.objects.create(from_node=node, to_node=end, condition_slug="go")
return workflow, node
def test_all_strategy_requires_all(self, user, admin_user):
workflow, node = self._setup(ApprovalStrategy.ALL)
engine = WorkflowEngine()
instance = engine.start("wf-strat", owner=user)
a1, a2 = engine.assign_users(instance.pk, node.id, [user, admin_user])
instance = engine.submit_decision(a1.id, "go", user=user)
assert instance.current_node.id == node.id
assert instance.status == WorkflowInstance.Status.IN_PROGRESS
instance = engine.submit_decision(a2.id, "go", user=admin_user)
assert instance.status == WorkflowInstance.Status.COMPLETED
def test_any_strategy_advances_with_one(self, user, admin_user):
workflow, node = self._setup(ApprovalStrategy.ANY)
engine = WorkflowEngine()
instance = engine.start("wf-strat", owner=user)
a1, _ = engine.assign_users(instance.pk, node.id, [user, admin_user])
instance = engine.submit_decision(a1.id, "go", user=user)
assert instance.status == WorkflowInstance.Status.COMPLETED
def test_majority_strategy(self, user, admin_user):
workflow, node = self._setup(ApprovalStrategy.MAJORITY)
other = admin_user.__class__.objects.create_user(
username="third", email="third@example.com", password="pw123456"
)
engine = WorkflowEngine()
instance = engine.start("wf-strat", owner=user)
a1, a2, _ = engine.assign_users(instance.pk, node.id, [user, admin_user, other])
instance = engine.submit_decision(a1.id, "go", user=user)
assert instance.current_node.id == node.id
assert instance.status == WorkflowInstance.Status.IN_PROGRESS
a2 = NodeAssignment.objects.get(instance=instance, user=admin_user, node=node)
instance = engine.submit_decision(a2.id, "go", user=admin_user)
assert instance.status == WorkflowInstance.Status.COMPLETED
class TestNodeStates:
def test_states_after_approval(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
states = engine.get_node_states(instance.pk)
assert states[instance.current_node_id] == "ACTIVE"
engine.submit_decision(assignment.id, "approved", user=user)
states = engine.get_node_states(instance.pk)
start_node = approval_workflow.nodes.get(name="Start")
review_node = approval_workflow.nodes.get(name="Review")
assert states[start_node.id] == "COMPLETED"
assert states[review_node.id] == "ACTIVE"
def test_rejected_state(self, user, approval_workflow):
review = approval_workflow.nodes.get(name="Review")
rejection = WorkflowNode.objects.create(
workflow=approval_workflow,
name="Rejected",
node_type=WorkflowNode.NodeType.END,
)
Transition.objects.create(from_node=review, to_node=rejection, condition_slug="rejected")
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
first = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
instance = engine.submit_decision(first.id, "approved", user=user)
second = NodeAssignment.objects.get(instance=instance, node=review, user=user)
instance = engine.submit_decision(second.id, "rejected", user=user)
states = engine.get_node_states(instance.pk)
assert states[review.id] == "REJECTED"
assert instance.status == WorkflowInstance.Status.COMPLETED
class TestRoute:
def test_route_lists_processed_nodes(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
engine.submit_decision(assignment.id, "approved", user=user, comments="go")
route = engine.get_route(instance.pk)
assert any(
entry["node_name"] == "Start"
and entry["decision"] == "approved"
and entry["comments"] == "go"
and entry["user"]["id"] == user.id
for entry in route
)
class TestRoles:
def test_owner(self, user, approval_workflow):
instance = WorkflowEngine().start("approval", owner=user)
assert WorkflowEngine().get_role_in_instance(instance.pk, user) == "OWNER"
def test_assignee(self, user, admin_user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=admin_user)
engine.assign_users(instance.pk, instance.current_node_id, [user])
assert engine.get_role_in_instance(instance.pk, user) == "ASSIGNEE"
def test_observer(self, user, admin_user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=admin_user)
engine.add_observer(instance.pk, user)
assert engine.get_role_in_instance(instance.pk, user) == "OBSERVER"
def test_none(self, user, admin_user, approval_workflow):
instance = WorkflowEngine().start("approval", owner=admin_user)
assert WorkflowEngine().get_role_in_instance(instance.pk, user) == "NONE"
assert WorkflowEngine().get_role_in_instance(instance.pk, None) == "NONE"
def test_processed_assignee_becomes_none(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
engine.submit_decision(assignment.id, "approved", user=user)
assert engine.get_role_in_instance(instance.pk, user) == "OWNER"
class TestAssignAndObservers:
def test_assign_users_creates_assignments(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
node = instance.current_node
assignments = engine.assign_users(instance.pk, node.id, [user])
assert len(assignments) == 1
assert assignments[0].instance_id == instance.pk
assert assignments[0].visit_number == 1
assert assignments[0].is_required is True
def test_assign_again_increments_visit(self, user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=user)
node = instance.current_node
engine.assign_users(instance.pk, node.id, [user])
engine.assign_users(instance.pk, node.id, [user], is_required=False)
assignments = NodeAssignment.objects.filter(instance=instance, node=node)
assert assignments.count() == 2
assert assignments.order_by("visit_number").last().visit_number == 2
def test_add_observer(self, user, admin_user, approval_workflow):
engine = WorkflowEngine()
instance = engine.start("approval", owner=admin_user)
observer = engine.add_observer(instance.pk, user)
assert observer.instance_id == instance.pk
assert WorkflowObserver.objects.filter(instance=instance, user=user).count() == 1
engine.add_observer(instance.pk, user)
assert WorkflowObserver.objects.filter(instance=instance, user=user).count() == 1
class TestDefaultTransition:
def test_default_transition_used_when_no_condition_match(self, user):
workflow = Workflow.objects.create(slug="wf-default", name="Default")
start = WorkflowNode.objects.create(workflow=workflow, name="Start", node_type=WorkflowNode.NodeType.START)
end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END)
Transition.objects.create(from_node=start, to_node=end, condition_slug="approved")
Transition.objects.create(from_node=start, to_node=end, condition_slug="", is_default=True)
engine = WorkflowEngine()
instance = engine.start("wf-default", owner=user)
assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0]
instance = engine.submit_decision(assignment.id, "anything", user=user)
assert instance.status == WorkflowInstance.Status.COMPLETED