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