307 lines
15 KiB
Python
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
|