feat: extract headless workflow engine
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Workflow test package for both discovery and targeted module execution."""
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_Choreography"
|
||||
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||
<bpmn:message id="Message_Approval" name="Approval" />
|
||||
<bpmn:collaboration id="Collaboration_Choreography">
|
||||
<bpmn:participant id="Participant_Applicant" name="Applicant" />
|
||||
<bpmn:participant id="Participant_Authority" name="Authority" />
|
||||
</bpmn:collaboration>
|
||||
<bpmn:choreography id="Choreography_1" name="Permit decision">
|
||||
<bpmn:startEvent id="Choreography_Start" />
|
||||
<bpmn:choreographyTask
|
||||
id="Choreography_Task"
|
||||
initiatingParticipantRef="Participant_Authority">
|
||||
<bpmn:participantRef>Participant_Authority</bpmn:participantRef>
|
||||
<bpmn:participantRef>Participant_Applicant</bpmn:participantRef>
|
||||
<bpmn:messageFlowRef>MessageFlow_Approval</bpmn:messageFlowRef>
|
||||
</bpmn:choreographyTask>
|
||||
<bpmn:endEvent id="Choreography_End" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Choreography_Flow_1"
|
||||
sourceRef="Choreography_Start"
|
||||
targetRef="Choreography_Task" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Choreography_Flow_2"
|
||||
sourceRef="Choreography_Task"
|
||||
targetRef="Choreography_End" />
|
||||
</bpmn:choreography>
|
||||
<bpmn:messageFlow
|
||||
id="MessageFlow_Approval"
|
||||
sourceRef="Participant_Authority"
|
||||
targetRef="Participant_Applicant"
|
||||
messageRef="Message_Approval" />
|
||||
</bpmn:definitions>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_Collaboration"
|
||||
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||
<bpmn:message id="Message_Request" name="Request" />
|
||||
<bpmn:process id="Process_Requester">
|
||||
<bpmn:startEvent id="Requester_Start" />
|
||||
<bpmn:sendTask id="Send_Request" messageRef="Message_Request" />
|
||||
</bpmn:process>
|
||||
<bpmn:process id="Process_Reviewer">
|
||||
<bpmn:receiveTask id="Receive_Request" messageRef="Message_Request" />
|
||||
<bpmn:endEvent id="Reviewer_End" />
|
||||
</bpmn:process>
|
||||
<bpmn:collaboration id="Collaboration_1">
|
||||
<bpmn:participant id="Participant_Requester" processRef="Process_Requester" />
|
||||
<bpmn:participant id="Participant_Reviewer" processRef="Process_Reviewer" />
|
||||
<bpmn:messageFlow
|
||||
id="MessageFlow_1"
|
||||
sourceRef="Send_Request"
|
||||
targetRef="Receive_Request"
|
||||
messageRef="Message_Request" />
|
||||
</bpmn:collaboration>
|
||||
</bpmn:definitions>
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_Control_Flow"
|
||||
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||
<bpmn:signal id="Signal_Escalation" name="Escalation" />
|
||||
<bpmn:process id="Called_Process" isExecutable="true">
|
||||
<bpmn:startEvent id="Called_Start" />
|
||||
<bpmn:userTask id="Called_Human_Task" name="Confirm result" />
|
||||
<bpmn:endEvent id="Called_End" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Called_Flow_1"
|
||||
sourceRef="Called_Start"
|
||||
targetRef="Called_Human_Task" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Called_Flow_2"
|
||||
sourceRef="Called_Human_Task"
|
||||
targetRef="Called_End" />
|
||||
</bpmn:process>
|
||||
<bpmn:process id="Control_Process" isExecutable="true">
|
||||
<bpmn:startEvent id="Control_Start" />
|
||||
<bpmn:exclusiveGateway id="Control_Decision" />
|
||||
<bpmn:subProcess id="Review_Subprocess" name="Review">
|
||||
<bpmn:startEvent id="Subprocess_Start" />
|
||||
<bpmn:userTask id="Subprocess_Review" name="Review request" />
|
||||
<bpmn:endEvent id="Subprocess_End" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Subprocess_Flow_1"
|
||||
sourceRef="Subprocess_Start"
|
||||
targetRef="Subprocess_Review" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Subprocess_Flow_2"
|
||||
sourceRef="Subprocess_Review"
|
||||
targetRef="Subprocess_End" />
|
||||
</bpmn:subProcess>
|
||||
<bpmn:boundaryEvent
|
||||
id="Review_Escalation"
|
||||
attachedToRef="Review_Subprocess"
|
||||
cancelActivity="false">
|
||||
<bpmn:signalEventDefinition
|
||||
id="Review_Escalation_Definition"
|
||||
signalRef="Signal_Escalation" />
|
||||
</bpmn:boundaryEvent>
|
||||
<bpmn:parallelGateway id="Control_Join" />
|
||||
<bpmn:callActivity
|
||||
id="Call_Confirmation"
|
||||
name="Confirm"
|
||||
calledElement="Called_Process" />
|
||||
<bpmn:intermediateThrowEvent id="Escalation_Thrown">
|
||||
<bpmn:signalEventDefinition
|
||||
id="Escalation_Thrown_Definition"
|
||||
signalRef="Signal_Escalation" />
|
||||
</bpmn:intermediateThrowEvent>
|
||||
<bpmn:task
|
||||
id="Compensation_Handler"
|
||||
name="Undo review"
|
||||
isForCompensation="true" />
|
||||
<bpmn:boundaryEvent
|
||||
id="Review_Compensation"
|
||||
attachedToRef="Review_Subprocess">
|
||||
<bpmn:compensateEventDefinition
|
||||
id="Review_Compensation_Definition"
|
||||
activityRef="Compensation_Handler" />
|
||||
</bpmn:boundaryEvent>
|
||||
<bpmn:endEvent id="Control_End" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Control_Flow_1"
|
||||
sourceRef="Control_Start"
|
||||
targetRef="Control_Decision" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Control_Flow_2"
|
||||
sourceRef="Control_Decision"
|
||||
targetRef="Review_Subprocess" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Control_Flow_3"
|
||||
sourceRef="Review_Subprocess"
|
||||
targetRef="Control_Join" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Control_Flow_4"
|
||||
sourceRef="Control_Join"
|
||||
targetRef="Call_Confirmation" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Control_Flow_5"
|
||||
sourceRef="Call_Confirmation"
|
||||
targetRef="Escalation_Thrown" />
|
||||
<bpmn:sequenceFlow
|
||||
id="Control_Flow_6"
|
||||
sourceRef="Escalation_Thrown"
|
||||
targetRef="Control_End" />
|
||||
</bpmn:process>
|
||||
</bpmn:definitions>
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_Data"
|
||||
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||
<bpmn:dataStore id="DataStore_Archive" name="Archive" />
|
||||
<bpmn:process id="Process_Data">
|
||||
<bpmn:dataObject id="DataObject_Request" name="Request" />
|
||||
<bpmn:dataObjectReference
|
||||
id="DataObjectReference_Request"
|
||||
dataObjectRef="DataObject_Request" />
|
||||
<bpmn:dataStoreReference
|
||||
id="DataStoreReference_Archive"
|
||||
dataStoreRef="DataStore_Archive" />
|
||||
<bpmn:scriptTask id="Transform_Data" name="Transform data">
|
||||
<bpmn:script>result = input</bpmn:script>
|
||||
<bpmn:dataInputAssociation id="InputAssociation_1">
|
||||
<bpmn:sourceRef>DataObjectReference_Request</bpmn:sourceRef>
|
||||
<bpmn:targetRef>Transform_Data</bpmn:targetRef>
|
||||
</bpmn:dataInputAssociation>
|
||||
<bpmn:dataOutputAssociation id="OutputAssociation_1">
|
||||
<bpmn:sourceRef>Transform_Data</bpmn:sourceRef>
|
||||
<bpmn:targetRef>DataStoreReference_Archive</bpmn:targetRef>
|
||||
</bpmn:dataOutputAssociation>
|
||||
</bpmn:scriptTask>
|
||||
</bpmn:process>
|
||||
</bpmn:definitions>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_Events"
|
||||
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||
<bpmn:message id="Message_Continue" name="Continue" />
|
||||
<bpmn:error id="Error_Processing" name="Processing failed" errorCode="PROCESSING" />
|
||||
<bpmn:process id="Process_Transaction" isExecutable="true">
|
||||
<bpmn:startEvent id="Start_Timer">
|
||||
<bpmn:timerEventDefinition id="Timer_Start_Definition">
|
||||
<bpmn:timeCycle>R3/PT1H</bpmn:timeCycle>
|
||||
</bpmn:timerEventDefinition>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:transaction id="Transaction_1">
|
||||
<bpmn:serviceTask id="Charge_Account" name="Charge account" />
|
||||
<bpmn:boundaryEvent
|
||||
id="Charge_Error"
|
||||
attachedToRef="Charge_Account">
|
||||
<bpmn:errorEventDefinition
|
||||
id="Charge_Error_Definition"
|
||||
errorRef="Error_Processing" />
|
||||
</bpmn:boundaryEvent>
|
||||
<bpmn:task
|
||||
id="Undo_Charge"
|
||||
name="Undo charge"
|
||||
isForCompensation="true" />
|
||||
<bpmn:association
|
||||
id="Compensation_Association"
|
||||
sourceRef="Charge_Error"
|
||||
targetRef="Undo_Charge"
|
||||
associationDirection="One" />
|
||||
</bpmn:transaction>
|
||||
<bpmn:intermediateCatchEvent id="Wait_For_Continue">
|
||||
<bpmn:messageEventDefinition
|
||||
id="Wait_Message_Definition"
|
||||
messageRef="Message_Continue" />
|
||||
</bpmn:intermediateCatchEvent>
|
||||
<bpmn:endEvent id="End_Transaction">
|
||||
<bpmn:terminateEventDefinition id="Terminate_Definition" />
|
||||
</bpmn:endEvent>
|
||||
</bpmn:process>
|
||||
</bpmn:definitions>
|
||||
Vendored
+50
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
|
||||
xmlns:govoplan="urn:govoplan:workflow:fixture-extension"
|
||||
id="Definitions_Process"
|
||||
targetNamespace="urn:govoplan:workflow:fixtures">
|
||||
<bpmn:process id="Process_Linear" isExecutable="true">
|
||||
<bpmn:extensionElements>
|
||||
<govoplan:fixture revision="1">
|
||||
<govoplan:note>Preserve this extension exactly.</govoplan:note>
|
||||
</govoplan:fixture>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:startEvent id="Start_1">
|
||||
<bpmn:outgoing>Flow_1</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:userTask id="Task_1" name="Review">
|
||||
<bpmn:incoming>Flow_1</bpmn:incoming>
|
||||
<bpmn:outgoing>Flow_2</bpmn:outgoing>
|
||||
</bpmn:userTask>
|
||||
<bpmn:endEvent id="End_1">
|
||||
<bpmn:incoming>Flow_2</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Task_1" />
|
||||
<bpmn:sequenceFlow id="Flow_2" sourceRef="Task_1" targetRef="End_1" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="Diagram_1">
|
||||
<bpmndi:BPMNPlane id="Plane_1" bpmnElement="Process_Linear">
|
||||
<bpmndi:BPMNShape id="Shape_Start_1" bpmnElement="Start_1">
|
||||
<dc:Bounds x="80" y="112" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_Task_1" bpmnElement="Task_1">
|
||||
<dc:Bounds x="220" y="90" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_End_1" bpmnElement="End_1">
|
||||
<dc:Bounds x="430" y="112" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="Edge_Flow_1" bpmnElement="Flow_1">
|
||||
<di:waypoint x="116" y="130" />
|
||||
<di:waypoint x="220" y="130" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="Edge_Flow_2" bpmnElement="Flow_2">
|
||||
<di:waypoint x="320" y="130" />
|
||||
<di:waypoint x="430" y="130" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
||||
@@ -0,0 +1,355 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_workflow_engine.backend.bpmn import (
|
||||
BPMN_MODEL_NAMESPACE,
|
||||
BpmnInspectionError,
|
||||
inspect_bpmn_xml,
|
||||
parse_bpmn_xml,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.bpmn_adapters import (
|
||||
BpmnAdapterError,
|
||||
INTERCHANGE_ADAPTER_ID,
|
||||
NATIVE_LINEAR_ADAPTER_ID,
|
||||
bpmn_adapter_registry,
|
||||
compile_bpmn_to_graph,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.bpmn_graph import (
|
||||
NATIVE_BPMN_ADAPTER_ID,
|
||||
export_bpmn_graph,
|
||||
import_bpmn_graph,
|
||||
)
|
||||
|
||||
|
||||
BPMN = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_1"
|
||||
targetNamespace="https://govoplan.example.test/workflow">
|
||||
<bpmn:process id="Process_1" isExecutable="true">
|
||||
<bpmn:startEvent id="Start_1" />
|
||||
<bpmn:userTask id="Review_1" name="Review request" />
|
||||
<bpmn:exclusiveGateway id="Decision_1" />
|
||||
<bpmn:endEvent id="End_1" />
|
||||
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Review_1" />
|
||||
<bpmn:sequenceFlow id="Flow_2" sourceRef="Review_1" targetRef="Decision_1" />
|
||||
<bpmn:sequenceFlow id="Flow_3" sourceRef="Decision_1" targetRef="End_1" />
|
||||
</bpmn:process>
|
||||
<bpmn:collaboration id="Collaboration_1">
|
||||
<bpmn:participant id="Participant_1" processRef="Process_1" />
|
||||
</bpmn:collaboration>
|
||||
</bpmn:definitions>
|
||||
"""
|
||||
|
||||
NATIVE_BPMN = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<bpmn:definitions
|
||||
xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
|
||||
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
|
||||
id="Definitions_native"
|
||||
targetNamespace="https://govoplan.example.test/workflow/native">
|
||||
<bpmn:process id="Process_native" isExecutable="true">
|
||||
<bpmn:startEvent id="Start_native" />
|
||||
<bpmn:userTask id="Review_native" name="Review request" />
|
||||
<bpmn:endEvent id="End_native" />
|
||||
<bpmn:sequenceFlow id="Flow_start_review" sourceRef="Start_native" targetRef="Review_native" />
|
||||
<bpmn:sequenceFlow id="Flow_review_end" sourceRef="Review_native" targetRef="End_native" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="Diagram_native">
|
||||
<bpmndi:BPMNPlane id="Plane_native" bpmnElement="Process_native">
|
||||
<bpmndi:BPMNShape id="Shape_start" bpmnElement="Start_native">
|
||||
<dc:Bounds x="40" y="120" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_review" bpmnElement="Review_native">
|
||||
<dc:Bounds x="220" y="90" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="Shape_end" bpmnElement="End_native">
|
||||
<dc:Bounds x="460" y="120" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
||||
"""
|
||||
|
||||
|
||||
class BpmnInspectionTests(unittest.TestCase):
|
||||
def test_notation_fixtures_are_safe_and_fully_inventoried(self) -> None:
|
||||
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
|
||||
results = {
|
||||
path.stem: inspect_bpmn_xml(path.read_text(encoding="utf-8"))
|
||||
for path in sorted(fixture_directory.glob("*.bpmn"))
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"choreography",
|
||||
"collaboration",
|
||||
"control-flow",
|
||||
"data",
|
||||
"events-transaction-compensation",
|
||||
"process",
|
||||
},
|
||||
set(results),
|
||||
)
|
||||
self.assertEqual(1, results["choreography"].choreography_count)
|
||||
self.assertEqual(1, results["collaboration"].collaboration_count)
|
||||
self.assertEqual(
|
||||
1,
|
||||
results["events-transaction-compensation"].element_counts[
|
||||
"transaction"
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
results["data"].element_counts["dataStoreReference"],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
results["control-flow"].element_counts["exclusiveGateway"],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
results["control-flow"].element_counts["subProcess"],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
results["control-flow"].element_counts["callActivity"],
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
results["control-flow"].element_counts[
|
||||
"compensateEventDefinition"
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
2,
|
||||
results["control-flow"].element_counts["signalEventDefinition"],
|
||||
)
|
||||
|
||||
def test_inventory_classifies_native_and_interchange_elements(self) -> None:
|
||||
result = inspect_bpmn_xml(BPMN)
|
||||
|
||||
self.assertTrue(result.valid_xml)
|
||||
self.assertEqual(1, result.process_count)
|
||||
self.assertEqual(1, result.executable_process_count)
|
||||
self.assertEqual(1, result.collaboration_count)
|
||||
self.assertEqual(3, result.element_counts["sequenceFlow"])
|
||||
review = next(
|
||||
item for item in result.elements if item.element_id == "Review_1"
|
||||
)
|
||||
collaboration = next(
|
||||
item
|
||||
for item in result.elements
|
||||
if item.element_id == "Collaboration_1"
|
||||
)
|
||||
self.assertEqual("native_execution", review.support_level)
|
||||
self.assertEqual("native_mapping", collaboration.support_level)
|
||||
|
||||
def test_dangling_references_are_reported(self) -> None:
|
||||
result = inspect_bpmn_xml(
|
||||
BPMN.replace('targetRef="End_1"', 'targetRef="Missing_1"')
|
||||
)
|
||||
|
||||
self.assertFalse(result.valid_xml)
|
||||
self.assertTrue(
|
||||
any(
|
||||
item.code == "dangling_bpmn_reference"
|
||||
for item in result.diagnostics
|
||||
)
|
||||
)
|
||||
|
||||
def test_entities_are_rejected(self) -> None:
|
||||
unsafe = """<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
|
||||
id="Definitions_1" targetNamespace="x">&xxe;</bpmn:definitions>"""
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
BpmnInspectionError,
|
||||
"not safe and well formed",
|
||||
):
|
||||
inspect_bpmn_xml(unsafe)
|
||||
|
||||
def test_non_bpmn_root_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
BpmnInspectionError,
|
||||
"bpmn:definitions",
|
||||
):
|
||||
inspect_bpmn_xml("<definitions />")
|
||||
|
||||
def test_profiles_are_versioned_and_native_compilation_is_stable(self) -> None:
|
||||
profiles = {
|
||||
item.id: item for item in bpmn_adapter_registry().profiles()
|
||||
}
|
||||
self.assertFalse(profiles[INTERCHANGE_ADAPTER_ID].executable)
|
||||
self.assertTrue(profiles[NATIVE_LINEAR_ADAPTER_ID].executable)
|
||||
self.assertTrue(profiles[NATIVE_BPMN_ADAPTER_ID].executable)
|
||||
|
||||
adapter, inspection, graph = compile_bpmn_to_graph(
|
||||
NATIVE_BPMN,
|
||||
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
)
|
||||
|
||||
self.assertEqual("1.0.0", adapter.profile.version)
|
||||
self.assertTrue(inspection.valid_xml)
|
||||
self.assertIsNotNone(graph)
|
||||
assert graph is not None
|
||||
self.assertEqual(
|
||||
[
|
||||
"workflow.start.manual",
|
||||
"workflow.activity",
|
||||
"workflow.end.completed",
|
||||
],
|
||||
[item.type for item in graph.nodes],
|
||||
)
|
||||
self.assertEqual(220, graph.nodes[1].position.x)
|
||||
self.assertEqual(
|
||||
["Flow_start_review", "Flow_review_end"],
|
||||
[item.id for item in graph.edges],
|
||||
)
|
||||
|
||||
def test_native_profile_rejects_semantics_it_cannot_execute(self) -> None:
|
||||
with self.assertRaisesRegex(
|
||||
BpmnAdapterError,
|
||||
"exclusiveGateway is not supported",
|
||||
):
|
||||
compile_bpmn_to_graph(
|
||||
BPMN,
|
||||
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
)
|
||||
|
||||
def test_model_only_profile_remains_read_compatible(self) -> None:
|
||||
_adapter, _inspection, graph = compile_bpmn_to_graph(
|
||||
BPMN,
|
||||
adapter_id=INTERCHANGE_ADAPTER_ID,
|
||||
)
|
||||
|
||||
self.assertIsNone(graph)
|
||||
|
||||
def test_native_bpmn_graph_imports_full_notation_and_round_trips(self) -> None:
|
||||
graph = import_bpmn_graph(BPMN)
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"bpmn.startEvent",
|
||||
"bpmn.userTask",
|
||||
"bpmn.exclusiveGateway",
|
||||
"bpmn.endEvent",
|
||||
"bpmn.participant",
|
||||
],
|
||||
[node.type for node in graph.nodes],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(edge.type == "bpmn.sequenceFlow" for edge in graph.edges)
|
||||
)
|
||||
rendered = export_bpmn_graph(graph, name="Round trip")
|
||||
imported = import_bpmn_graph(rendered)
|
||||
self.assertEqual(
|
||||
[(node.id, node.type) for node in graph.nodes],
|
||||
[(node.id, node.type) for node in imported.nodes],
|
||||
)
|
||||
self.assertEqual(
|
||||
[(edge.id, edge.type, edge.source, edge.target) for edge in graph.edges],
|
||||
[
|
||||
(edge.id, edge.type, edge.source, edge.target)
|
||||
for edge in imported.edges
|
||||
],
|
||||
)
|
||||
|
||||
def test_all_bpmn_fixtures_round_trip_through_the_native_graph(self) -> None:
|
||||
fixture_directory = Path(__file__).parent / "fixtures" / "bpmn"
|
||||
|
||||
for path in sorted(fixture_directory.glob("*.bpmn")):
|
||||
with self.subTest(path=path.name):
|
||||
graph = import_bpmn_graph(path.read_text(encoding="utf-8"))
|
||||
imported = import_bpmn_graph(
|
||||
export_bpmn_graph(graph, name=path.stem)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
Counter(node.type for node in graph.nodes),
|
||||
Counter(node.type for node in imported.nodes),
|
||||
)
|
||||
self.assertEqual(
|
||||
Counter(edge.type for edge in graph.edges),
|
||||
Counter(edge.type for edge in imported.edges),
|
||||
)
|
||||
self.assertEqual(len(graph.nodes), len(imported.nodes))
|
||||
self.assertEqual(len(graph.edges), len(imported.edges))
|
||||
|
||||
def test_nested_flows_remain_in_their_bpmn_container(self) -> None:
|
||||
fixture = (
|
||||
Path(__file__).parent
|
||||
/ "fixtures"
|
||||
/ "bpmn"
|
||||
/ "events-transaction-compensation.bpmn"
|
||||
)
|
||||
rendered = export_bpmn_graph(
|
||||
import_bpmn_graph(fixture.read_text(encoding="utf-8"))
|
||||
)
|
||||
root = parse_bpmn_xml(rendered)
|
||||
transaction = next(
|
||||
item
|
||||
for item in root.iter()
|
||||
if item.tag == f"{{{BPMN_MODEL_NAMESPACE}}}transaction"
|
||||
)
|
||||
|
||||
self.assertTrue(
|
||||
any(
|
||||
child.tag == f"{{{BPMN_MODEL_NAMESPACE}}}association"
|
||||
and child.attrib.get("id") == "Compensation_Association"
|
||||
for child in transaction
|
||||
)
|
||||
)
|
||||
|
||||
def test_default_flow_is_an_editable_edge_property(self) -> None:
|
||||
source = BPMN.replace(
|
||||
'<bpmn:exclusiveGateway id="Decision_1" />',
|
||||
'<bpmn:exclusiveGateway id="Decision_1" default="Flow_3" />',
|
||||
)
|
||||
graph = import_bpmn_graph(source)
|
||||
default_edge = next(edge for edge in graph.edges if edge.id == "Flow_3")
|
||||
|
||||
self.assertIs(default_edge.config.get("default"), True)
|
||||
rendered = export_bpmn_graph(graph)
|
||||
self.assertIn('default="Flow_3"', rendered)
|
||||
|
||||
graph.edges = [
|
||||
edge.model_copy(update={"config": {**edge.config, "default": False}})
|
||||
if edge.id == "Flow_3"
|
||||
else edge
|
||||
for edge in graph.edges
|
||||
]
|
||||
rendered_without_default = export_bpmn_graph(graph)
|
||||
self.assertNotIn('default="Flow_3"', rendered_without_default)
|
||||
|
||||
def test_native_graph_import_is_separate_from_runtime_support(self) -> None:
|
||||
_adapter, _inspection, graph = compile_bpmn_to_graph(
|
||||
BPMN,
|
||||
adapter_id=NATIVE_BPMN_ADAPTER_ID,
|
||||
)
|
||||
self.assertIsNotNone(graph)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
BpmnAdapterError,
|
||||
"exclusive gateway",
|
||||
):
|
||||
compile_bpmn_to_graph(
|
||||
BPMN,
|
||||
adapter_id=NATIVE_BPMN_ADAPTER_ID,
|
||||
activation=True,
|
||||
)
|
||||
|
||||
def test_adapter_versions_are_resolved_exactly_when_pinned(self) -> None:
|
||||
with self.assertRaisesRegex(BpmnAdapterError, "is not installed"):
|
||||
compile_bpmn_to_graph(
|
||||
NATIVE_BPMN,
|
||||
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
adapter_version="9.0.0",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,338 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.policy import PolicyDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.workflows import WorkflowDefinitionContribution
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.contributions import (
|
||||
reconcile_workflow_definition_contributions,
|
||||
reset_workflow_override_to_standard,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.instance_service import start_instance
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
WorkflowDefinitionDeriveRequest,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowInstanceStartRequest,
|
||||
WorkflowNode,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.service import (
|
||||
WorkflowConflictError,
|
||||
definition_response,
|
||||
derive_definition,
|
||||
update_definition,
|
||||
)
|
||||
|
||||
|
||||
def contribution_graph(*, label: str = "Approve request") -> WorkflowGraph:
|
||||
return WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.manual",
|
||||
label="Start",
|
||||
config={"input_schema_ref": ""},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="review",
|
||||
type="workflow.activity",
|
||||
label=label,
|
||||
config={
|
||||
"title": label,
|
||||
"instructions": "",
|
||||
"assignee": "",
|
||||
"due_after": "",
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="complete",
|
||||
type="workflow.end.completed",
|
||||
label="Complete",
|
||||
config={"output_mapping": {}},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="start-review", source="start", target="review"),
|
||||
WorkflowEdge(id="review-complete", source="review", target="complete"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def contribution(
|
||||
*,
|
||||
version: str = "1.0.0",
|
||||
label: str = "Approve request",
|
||||
) -> WorkflowDefinitionContribution:
|
||||
return WorkflowDefinitionContribution(
|
||||
origin_module_id="permits",
|
||||
origin_module_version=version,
|
||||
definition_key="application-approval",
|
||||
name="Application approval",
|
||||
description="Module-provided approval baseline.",
|
||||
graph=contribution_graph(label=label).model_dump(mode="json"),
|
||||
allow_automation=False,
|
||||
metadata={"domain": "permits"},
|
||||
)
|
||||
|
||||
|
||||
def registry_for(item: WorkflowDefinitionContribution) -> PlatformRegistry:
|
||||
registry = PlatformRegistry()
|
||||
registry.register(
|
||||
ModuleManifest(
|
||||
id="permits",
|
||||
name="Permits",
|
||||
version=item.origin_module_version,
|
||||
workflow_definitions=(item,),
|
||||
)
|
||||
)
|
||||
registry.validate()
|
||||
return registry
|
||||
|
||||
|
||||
def principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"workflow:definition:read",
|
||||
"workflow:definition:write",
|
||||
"workflow:instance:admin",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
class _DefinitionPolicy:
|
||||
def resolve_definition_action(self, *, request):
|
||||
return PolicyDecision(
|
||||
allowed=(
|
||||
request.action in {"view", "derive", "reuse"}
|
||||
and request.allow_reuse
|
||||
),
|
||||
reason=None,
|
||||
)
|
||||
|
||||
|
||||
class _PolicyRegistry:
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == "policy.definitionGovernance"
|
||||
|
||||
def capability(self, name: str):
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return _DefinitionPolicy()
|
||||
|
||||
|
||||
class WorkflowContributionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinition.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowInstance.__table__,
|
||||
WorkflowInstanceStep.__table__,
|
||||
WorkflowInstanceEvent.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowInstanceEvent.__table__,
|
||||
WorkflowInstanceStep.__table__,
|
||||
WorkflowInstance.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowDefinition.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_reconciliation_is_idempotent_and_does_not_auto_activate_updates(self) -> None:
|
||||
first = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(contribution()),
|
||||
)
|
||||
repeated = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(contribution()),
|
||||
)
|
||||
upgraded = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(
|
||||
contribution(version="1.1.0", label="Approve corrected request")
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
definition = self.session.scalar(select(WorkflowDefinition))
|
||||
self.assertIsNotNone(definition)
|
||||
assert definition is not None
|
||||
self.assertEqual(1, first["created"])
|
||||
self.assertEqual(1, repeated["unchanged"])
|
||||
self.assertEqual(1, upgraded["updated"])
|
||||
self.assertEqual(2, definition.current_revision)
|
||||
self.assertEqual(1, definition.active_revision)
|
||||
self.assertEqual("permits", definition.standard_origin_module_id)
|
||||
self.assertEqual("1.1.0", definition.standard_origin_module_version)
|
||||
self.assertEqual(2, len(definition.revisions))
|
||||
self.assertNotEqual(
|
||||
definition.revisions[0].contribution_hash,
|
||||
definition.revisions[1].contribution_hash,
|
||||
)
|
||||
|
||||
def test_standard_is_immutable_and_reset_archives_only_the_override(self) -> None:
|
||||
reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(contribution()),
|
||||
)
|
||||
baseline = self.session.scalar(select(WorkflowDefinition))
|
||||
assert baseline is not None
|
||||
with self.assertRaisesRegex(WorkflowConflictError, "immutable"):
|
||||
update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=baseline.id,
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name=baseline.name,
|
||||
graph=contribution_graph(),
|
||||
expected_revision=baseline.current_revision,
|
||||
scope_type="system",
|
||||
),
|
||||
)
|
||||
override = derive_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=_PolicyRegistry(),
|
||||
source_definition_id=baseline.id,
|
||||
payload=WorkflowDefinitionDeriveRequest(
|
||||
name="Tenant approval override",
|
||||
scope_type="tenant",
|
||||
),
|
||||
)
|
||||
initial = definition_response(
|
||||
self.session,
|
||||
override,
|
||||
principal=principal(),
|
||||
registry=_PolicyRegistry(),
|
||||
)
|
||||
self.assertIsNotNone(initial.standard)
|
||||
assert initial.standard is not None
|
||||
self.assertFalse(initial.standard.update_available)
|
||||
|
||||
reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(
|
||||
contribution(version="1.1.0", label="Approve corrected request")
|
||||
),
|
||||
)
|
||||
drifted = definition_response(
|
||||
self.session,
|
||||
override,
|
||||
principal=principal(),
|
||||
registry=_PolicyRegistry(),
|
||||
)
|
||||
self.assertIsNotNone(drifted.standard)
|
||||
assert drifted.standard is not None
|
||||
self.assertTrue(drifted.standard.update_available)
|
||||
|
||||
restored = reset_workflow_override_to_standard(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=override.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(baseline.id, restored.id)
|
||||
self.assertEqual("archived", override.status)
|
||||
self.assertEqual("active", baseline.status)
|
||||
self.assertEqual(
|
||||
baseline.id,
|
||||
override.metadata_["standard_reset"]["baseline_definition_id"],
|
||||
)
|
||||
|
||||
def test_missing_requirements_fail_closed_without_creating_a_baseline(self) -> None:
|
||||
item = replace(
|
||||
contribution(),
|
||||
required_capabilities=("permits.approvalAction",),
|
||||
)
|
||||
result = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry_for(item),
|
||||
)
|
||||
|
||||
self.assertEqual(1, result["blocked"])
|
||||
self.assertIsNone(self.session.scalar(select(WorkflowDefinition)))
|
||||
self.assertEqual(
|
||||
["capability:permits.approvalAction"],
|
||||
result["items"][0]["missing"], # type: ignore[index]
|
||||
)
|
||||
|
||||
def test_domain_contribution_runs_without_the_editor_module(self) -> None:
|
||||
item = replace(
|
||||
contribution(),
|
||||
scope_type="tenant",
|
||||
inherit_to_lower_scopes=False,
|
||||
)
|
||||
registry = registry_for(item)
|
||||
result = reconcile_workflow_definition_contributions(
|
||||
self.session,
|
||||
registry=registry,
|
||||
tenant_ids=("tenant-1",),
|
||||
)
|
||||
definition = self.session.scalar(select(WorkflowDefinition))
|
||||
assert definition is not None
|
||||
|
||||
instance, replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="headless-domain-run",
|
||||
input={"request_id": "request-1"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(1, result["created"])
|
||||
self.assertFalse(replayed)
|
||||
self.assertEqual("waiting", instance.status)
|
||||
self.assertEqual(definition.revisions[0].id, instance.definition_revision_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.policy import PolicyDecision
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.governance import normalize_definition_scope
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowDefinitionDeriveRequest,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.service import (
|
||||
WorkflowConflictError,
|
||||
activate_definition,
|
||||
create_definition,
|
||||
definition_response,
|
||||
derive_definition,
|
||||
update_definition,
|
||||
)
|
||||
try:
|
||||
from test_service import sample_graph
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "test_service":
|
||||
raise
|
||||
from tests.test_service import sample_graph
|
||||
|
||||
|
||||
POLICY_CAPABILITY = "policy.definitionGovernance"
|
||||
|
||||
|
||||
def principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"workflow:definition:read",
|
||||
"workflow:definition:write",
|
||||
"workflow:instance:start",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
class DefinitionPolicy:
|
||||
def resolve_definition_action(self, *, request):
|
||||
local = request.definition_scope.scope_type == "tenant"
|
||||
inherited = (
|
||||
request.definition_scope.scope_type == "system"
|
||||
and request.inherit_to_lower_scopes
|
||||
)
|
||||
allowed = local or inherited
|
||||
if request.action == "edit":
|
||||
allowed = local
|
||||
elif request.action in {"reuse", "derive"}:
|
||||
allowed = allowed and request.allow_reuse
|
||||
elif request.action == "run":
|
||||
allowed = (
|
||||
allowed
|
||||
and request.definition_kind == "flow"
|
||||
and request.status == "active"
|
||||
and request.allow_run
|
||||
)
|
||||
elif request.action == "automate":
|
||||
allowed = (
|
||||
allowed
|
||||
and request.definition_kind == "flow"
|
||||
and request.allow_automation
|
||||
)
|
||||
return PolicyDecision(
|
||||
allowed=allowed,
|
||||
reason=None if allowed else "Definition action denied.",
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == POLICY_CAPABILITY
|
||||
|
||||
def capability(self, name: str):
|
||||
if not self.has_capability(name):
|
||||
raise KeyError(name)
|
||||
return DefinitionPolicy()
|
||||
|
||||
|
||||
class WorkflowGovernanceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinition.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
self.principal = principal()
|
||||
self.registry = Registry()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowDefinition.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def test_derivation_pins_template_revision_and_provenance(self) -> None:
|
||||
template = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="system-admin",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
key="permit-review",
|
||||
name="Permit review template",
|
||||
graph=sample_graph(),
|
||||
scope_type="system",
|
||||
definition_kind="template",
|
||||
inherit_to_lower_scopes=True,
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
derived = derive_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=self.principal,
|
||||
registry=self.registry,
|
||||
source_definition_id=template.id,
|
||||
payload=WorkflowDefinitionDeriveRequest(
|
||||
name="Tenant permit review",
|
||||
allow_start=True,
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
response = definition_response(
|
||||
self.session,
|
||||
derived,
|
||||
principal=self.principal,
|
||||
registry=self.registry,
|
||||
)
|
||||
self.assertEqual(template.id, derived.derived_from_definition_id)
|
||||
self.assertEqual(1, derived.derived_from_revision)
|
||||
self.assertEqual(
|
||||
template.revisions[0].content_hash,
|
||||
derived.derived_from_hash,
|
||||
)
|
||||
self.assertEqual(
|
||||
"system",
|
||||
response.governance.derivation_provenance["source_scope"][
|
||||
"scope_type"
|
||||
],
|
||||
)
|
||||
self.assertFalse(response.governance.automation_runtime_available)
|
||||
|
||||
def test_user_scope_normalizes_membership_to_account_id(self) -> None:
|
||||
tenant_id, scope_type, scope_id, scope_key = normalize_definition_scope(
|
||||
self.principal,
|
||||
scope_type="user",
|
||||
scope_id="membership-1",
|
||||
administrative=False,
|
||||
)
|
||||
|
||||
self.assertEqual("tenant-1", tenant_id)
|
||||
self.assertEqual("user", scope_type)
|
||||
self.assertEqual("account-1", scope_id)
|
||||
self.assertEqual("user:account-1", scope_key)
|
||||
|
||||
def test_template_cannot_be_activated(self) -> None:
|
||||
template = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Reusable review",
|
||||
graph=sample_graph(),
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaises(WorkflowConflictError):
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=template.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
|
||||
def test_derived_limits_cannot_be_broadened_transitively(self) -> None:
|
||||
template = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Restricted review",
|
||||
graph=sample_graph(),
|
||||
definition_kind="template",
|
||||
allow_reuse=True,
|
||||
allow_automation=False,
|
||||
inherit_to_lower_scopes=False,
|
||||
),
|
||||
)
|
||||
derived = derive_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=self.principal,
|
||||
registry=self.registry,
|
||||
source_definition_id=template.id,
|
||||
payload=WorkflowDefinitionDeriveRequest(
|
||||
name="Tenant review",
|
||||
allow_reuse=True,
|
||||
allow_automation=True,
|
||||
inherit_to_lower_scopes=True,
|
||||
),
|
||||
)
|
||||
update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=derived.id,
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name=derived.name,
|
||||
graph=sample_graph(),
|
||||
expected_revision=1,
|
||||
allow_reuse=True,
|
||||
allow_automation=True,
|
||||
inherit_to_lower_scopes=True,
|
||||
),
|
||||
)
|
||||
grandchild = derive_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
principal=self.principal,
|
||||
registry=self.registry,
|
||||
source_definition_id=derived.id,
|
||||
payload=WorkflowDefinitionDeriveRequest(
|
||||
name="User review",
|
||||
scope_type="user",
|
||||
scope_id="membership-1",
|
||||
allow_automation=True,
|
||||
inherit_to_lower_scopes=True,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertFalse(derived.allow_automation)
|
||||
self.assertFalse(derived.inherit_to_lower_scopes)
|
||||
self.assertFalse(grandchild.allow_automation)
|
||||
self.assertFalse(grandchild.inherit_to_lower_scopes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,987 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||
PrincipalRef,
|
||||
)
|
||||
from govoplan_core.core.automation import (
|
||||
ActionDefinition,
|
||||
ActionExecutionResult,
|
||||
ActionPreview,
|
||||
AutomationPrincipalResolution,
|
||||
EffectDefinition,
|
||||
EffectPreview,
|
||||
ObservedEffect,
|
||||
)
|
||||
from govoplan_core.core.dataflows import (
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
DataflowRunDescriptor,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceEvent,
|
||||
WorkflowInstanceStep,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.instance_service import (
|
||||
SqlWorkflowRuntimeWorker,
|
||||
cancel_instance,
|
||||
instance_response,
|
||||
reconcile_instance,
|
||||
resolve_step,
|
||||
start_instance,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
BpmnRevisionInput,
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowInstanceStartRequest,
|
||||
WorkflowNode,
|
||||
WorkflowStepActionRequest,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.bpmn_adapters import NATIVE_LINEAR_ADAPTER_ID
|
||||
from govoplan_workflow_engine.backend.service import (
|
||||
WorkflowConflictError,
|
||||
activate_definition,
|
||||
create_definition,
|
||||
)
|
||||
try:
|
||||
from test_bpmn import NATIVE_BPMN
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "test_bpmn":
|
||||
raise
|
||||
from tests.test_bpmn import NATIVE_BPMN
|
||||
|
||||
|
||||
def principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"workflow:definition:read",
|
||||
"workflow:instance:read",
|
||||
"workflow:instance:start",
|
||||
"workflow:instance:transition",
|
||||
"dataflow:pipeline:run",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=object(),
|
||||
user=object(),
|
||||
)
|
||||
|
||||
|
||||
def runtime_graph() -> WorkflowGraph:
|
||||
return WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.manual",
|
||||
label="Start",
|
||||
config={"input_schema_ref": ""},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="flow",
|
||||
type="workflow.dataflow",
|
||||
label="Prepare evidence",
|
||||
config={
|
||||
"pipeline_ref": "pipeline:pipeline-1",
|
||||
"revision": 3,
|
||||
"environment": "development",
|
||||
"row_limit": 250,
|
||||
"publication_target_ref": "",
|
||||
"warning_policy": "review",
|
||||
"input_mapping": {},
|
||||
"view_surface_ids": [
|
||||
"dataflow.module",
|
||||
"dataflow.route.pipelines",
|
||||
],
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="complete",
|
||||
type="workflow.end.completed",
|
||||
label="Complete",
|
||||
config={"output_mapping": {}},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="cancelled",
|
||||
type="workflow.end.cancelled",
|
||||
label="Rejected",
|
||||
config={"reason": "Rejected during review"},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(
|
||||
id="start-flow",
|
||||
source="start",
|
||||
target="flow",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="flow-complete",
|
||||
source="flow",
|
||||
source_port="success",
|
||||
target="complete",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="flow-warning",
|
||||
source="flow",
|
||||
source_port="warning",
|
||||
target="complete",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="flow-review",
|
||||
source="flow",
|
||||
source_port="review_required",
|
||||
target="complete",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="flow-failure",
|
||||
source="flow",
|
||||
source_port="failure",
|
||||
target="cancelled",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def action_graph() -> WorkflowGraph:
|
||||
return WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.manual",
|
||||
config={"input_schema_ref": ""},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="action",
|
||||
type="workflow.capability",
|
||||
config={
|
||||
"capability": "test.actions",
|
||||
"operation": "test.case.record",
|
||||
"input_mapping": {"case_id": "$input.case_id"},
|
||||
"idempotency_key": "$input.case_id",
|
||||
"failure_policy": "manual",
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="complete",
|
||||
type="workflow.end.completed",
|
||||
config={"output_mapping": {}},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="failed",
|
||||
type="workflow.end.cancelled",
|
||||
config={"reason": "Action rejected"},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(
|
||||
id="start-action",
|
||||
source="start",
|
||||
target="action",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="action-complete",
|
||||
source="action",
|
||||
source_port="success",
|
||||
target="complete",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="action-failed",
|
||||
source="action",
|
||||
source_port="failure",
|
||||
target="failed",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FakeDataflowLifecycle:
|
||||
def __init__(self) -> None:
|
||||
self.runs: dict[str, DataflowRunDescriptor] = {}
|
||||
self.requests = []
|
||||
self.cancelled: list[str] = []
|
||||
|
||||
def start_run(self, _session, _principal, *, request):
|
||||
self.requests.append(request)
|
||||
run_ref = f"run:{len(self.requests)}"
|
||||
descriptor = DataflowRunDescriptor(
|
||||
ref=run_ref,
|
||||
pipeline_ref=request.pipeline_ref,
|
||||
revision=request.revision,
|
||||
status="queued",
|
||||
definition_hash="definition-hash",
|
||||
executor_version="test",
|
||||
metadata={"progress_percent": 0, "progress_phase": "queued"},
|
||||
)
|
||||
self.runs[run_ref] = descriptor
|
||||
return descriptor
|
||||
|
||||
def get_run(self, _session, _principal, *, run_ref):
|
||||
return self.runs.get(run_ref)
|
||||
|
||||
def cancel_run(self, _session, _principal, *, run_ref):
|
||||
descriptor = self.runs[run_ref]
|
||||
descriptor = replace(descriptor, status="cancelled")
|
||||
self.runs[run_ref] = descriptor
|
||||
self.cancelled.append(run_ref)
|
||||
return descriptor
|
||||
|
||||
def finish(
|
||||
self,
|
||||
run_ref: str,
|
||||
*,
|
||||
diagnostics: list[dict[str, object]] | None = None,
|
||||
) -> None:
|
||||
self.runs[run_ref] = replace(
|
||||
self.runs[run_ref],
|
||||
status="succeeded",
|
||||
output_publication_ref="publication:1",
|
||||
output_datasource_ref="datasource:1",
|
||||
output_materialization_ref="materialization:1",
|
||||
input_row_count=12,
|
||||
output_row_count=10,
|
||||
metadata={"diagnostics": diagnostics or []},
|
||||
)
|
||||
|
||||
def fail(self, run_ref: str) -> None:
|
||||
self.runs[run_ref] = replace(
|
||||
self.runs[run_ref],
|
||||
status="failed",
|
||||
error="Data quality gate failed.",
|
||||
)
|
||||
|
||||
|
||||
class FakeAutomationProvider:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def resolve_automation_principal(self, _session, *, request):
|
||||
self.requests.append(request)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal=principal(),
|
||||
granted_scopes=request.grant_scopes,
|
||||
provenance={"status": "rechecked"},
|
||||
)
|
||||
|
||||
|
||||
class FakeActionProvider:
|
||||
action = ActionDefinition(
|
||||
action_key="test.case.record",
|
||||
owner_module="test",
|
||||
description="Record a test case.",
|
||||
input_schema_ref="schema:test.case.record@1",
|
||||
expected_effect_keys=("test.case.recorded",),
|
||||
)
|
||||
effect = EffectDefinition(
|
||||
effect_key="test.case.recorded",
|
||||
owner_module="test",
|
||||
operation="created",
|
||||
description="A test case was recorded.",
|
||||
)
|
||||
|
||||
def __init__(self, *states: str) -> None:
|
||||
self.states = list(states or ("completed",))
|
||||
self.requests = []
|
||||
|
||||
def action_definitions(self):
|
||||
return (self.action,)
|
||||
|
||||
def effect_definitions(self):
|
||||
return (self.effect,)
|
||||
|
||||
def preview_action(self, _session, _principal, *, request):
|
||||
return ActionPreview(
|
||||
action_key=request.action_key,
|
||||
allowed=True,
|
||||
summary="Record one case.",
|
||||
risk_level=self.action.risk_level,
|
||||
reversibility=self.action.reversibility,
|
||||
effects=(
|
||||
EffectPreview(
|
||||
effect_key=self.effect.effect_key,
|
||||
summary="Record case.",
|
||||
),
|
||||
),
|
||||
preview_ref="preview:test",
|
||||
)
|
||||
|
||||
def execute_action(self, _session, _principal, *, request):
|
||||
self.requests.append(request)
|
||||
state = self.states.pop(0) if self.states else "completed"
|
||||
if state != "completed":
|
||||
return ActionExecutionResult(
|
||||
state=state,
|
||||
error="Temporary action failure.",
|
||||
)
|
||||
return ActionExecutionResult(
|
||||
state="completed",
|
||||
output={"case_ref": "case:1"},
|
||||
observed_effects=(
|
||||
ObservedEffect(
|
||||
effect_key=self.effect.effect_key,
|
||||
operation="created",
|
||||
resource_ref="case:1",
|
||||
),
|
||||
),
|
||||
audit_event_refs=("audit:1",),
|
||||
)
|
||||
|
||||
|
||||
class Registry:
|
||||
def __init__(
|
||||
self,
|
||||
dataflow: FakeDataflowLifecycle,
|
||||
automation: FakeAutomationProvider | None = None,
|
||||
action: FakeActionProvider | None = None,
|
||||
) -> None:
|
||||
self.dataflow = dataflow
|
||||
self.automation = automation
|
||||
self.action = action
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE or (
|
||||
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||
and self.automation is not None
|
||||
) or (
|
||||
name == "test.actions"
|
||||
and self.action is not None
|
||||
)
|
||||
|
||||
def capability(self, name: str):
|
||||
if name == CAPABILITY_DATAFLOW_RUN_LIFECYCLE:
|
||||
return self.dataflow
|
||||
if (
|
||||
name == CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
|
||||
and self.automation is not None
|
||||
):
|
||||
return self.automation
|
||||
if name == "test.actions" and self.action is not None:
|
||||
return self.action
|
||||
raise KeyError(name)
|
||||
|
||||
|
||||
class WorkflowInstanceServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinition.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowInstance.__table__,
|
||||
WorkflowInstanceStep.__table__,
|
||||
WorkflowInstanceEvent.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
self.dataflow = FakeDataflowLifecycle()
|
||||
self.registry = Registry(self.dataflow)
|
||||
self.definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Monthly governed processing",
|
||||
graph=runtime_graph(),
|
||||
execution_mode="hybrid",
|
||||
view_id="view-1",
|
||||
view_revision_id="view-revision-1",
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=self.definition.id,
|
||||
actor_id="account-1",
|
||||
revision=1,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowInstanceEvent.__table__,
|
||||
WorkflowInstanceStep.__table__,
|
||||
WorkflowInstance.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowDefinition.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def _start(self, key: str = "request-1") -> WorkflowInstance:
|
||||
instance, replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=self.definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key=key,
|
||||
input={"case_id": "case-1"},
|
||||
correlation_id="correlation-1",
|
||||
),
|
||||
)
|
||||
self.assertFalse(replayed)
|
||||
return instance
|
||||
|
||||
def test_start_pins_revision_and_replays_idempotently(self) -> None:
|
||||
instance = self._start()
|
||||
replayed, was_replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=self.definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="request-1",
|
||||
input={"case_id": "case-1"},
|
||||
correlation_id="correlation-1",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertTrue(was_replayed)
|
||||
self.assertEqual(instance.id, replayed.id)
|
||||
self.assertEqual("waiting", instance.status)
|
||||
self.assertEqual("user", instance.start_origin)
|
||||
self.assertEqual(1, len(self.dataflow.requests))
|
||||
response = instance_response(self.session, instance)
|
||||
self.assertEqual("hybrid", response.execution_mode)
|
||||
self.assertEqual("user", response.start_origin)
|
||||
self.assertIsNotNone(response.view_context)
|
||||
self.assertEqual(
|
||||
[
|
||||
"dataflow.module",
|
||||
"dataflow.route.pipelines",
|
||||
],
|
||||
response.view_context.visible_surface_ids,
|
||||
)
|
||||
self.assertEqual([1, 2], [step.sequence for step in response.steps])
|
||||
self.assertEqual("run:1", response.steps[-1].external_ref)
|
||||
self.assertGreaterEqual(len(response.events), 4)
|
||||
|
||||
with self.assertRaises(WorkflowConflictError):
|
||||
start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=self.definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="request-1",
|
||||
input={"case_id": "another-case"},
|
||||
correlation_id="correlation-1",
|
||||
),
|
||||
)
|
||||
|
||||
def test_guided_workflow_rejects_automated_start_origin(self) -> None:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Guided review",
|
||||
graph=WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.api",
|
||||
config={
|
||||
"input_schema_ref": "schema:input",
|
||||
"authorization_policy_ref": "policy:start",
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="activity",
|
||||
type="workflow.activity",
|
||||
config={"title": "Review"},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="done",
|
||||
type="workflow.end.completed",
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(
|
||||
id="start-activity",
|
||||
source="start",
|
||||
target="activity",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="activity-done",
|
||||
source="activity",
|
||||
target="done",
|
||||
),
|
||||
],
|
||||
),
|
||||
execution_mode="guided",
|
||||
allow_automation=True,
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
WorkflowConflictError,
|
||||
"Guided workflows must be started by a user",
|
||||
):
|
||||
start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="automated-guided",
|
||||
),
|
||||
start_origin="api",
|
||||
)
|
||||
|
||||
def test_module_action_records_effects_and_completes_idempotently(
|
||||
self,
|
||||
) -> None:
|
||||
action = FakeActionProvider()
|
||||
registry = Registry(self.dataflow, action=action)
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Action workflow",
|
||||
graph=action_graph(),
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
|
||||
instance, replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="action-instance",
|
||||
input={"case_id": "case-1"},
|
||||
),
|
||||
)
|
||||
response = instance_response(self.session, instance)
|
||||
|
||||
self.assertFalse(replayed)
|
||||
self.assertEqual("completed", response.status)
|
||||
self.assertEqual(1, len(action.requests))
|
||||
self.assertEqual(
|
||||
"test.actions:test.case.record:case-1",
|
||||
action.requests[0].idempotency_key,
|
||||
)
|
||||
self.assertEqual(
|
||||
"case:1",
|
||||
response.steps[1].output["execution"]["observed_effects"][0][
|
||||
"resource_ref"
|
||||
],
|
||||
)
|
||||
self.assertIn(
|
||||
"workflow.action.completed",
|
||||
{event.kind for event in response.events},
|
||||
)
|
||||
|
||||
def test_retryable_module_action_reuses_the_idempotency_key(self) -> None:
|
||||
action = FakeActionProvider("retryable", "completed")
|
||||
registry = Registry(self.dataflow, action=action)
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Retry action",
|
||||
graph=action_graph(),
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
instance, _replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="retry-action-instance",
|
||||
input={"case_id": "case-2"},
|
||||
),
|
||||
)
|
||||
waiting = instance_response(self.session, instance)
|
||||
current = waiting.steps[-1]
|
||||
self.assertEqual("retryable", current.handoff["state"])
|
||||
|
||||
resolved = resolve_step(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=current.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=registry,
|
||||
payload=WorkflowStepActionRequest(action="retry"),
|
||||
)
|
||||
|
||||
self.assertEqual("completed", resolved.status)
|
||||
self.assertEqual(
|
||||
action.requests[0].idempotency_key,
|
||||
action.requests[1].idempotency_key,
|
||||
)
|
||||
|
||||
def test_automated_dataflow_failure_policy_fails_without_handoff(
|
||||
self,
|
||||
) -> None:
|
||||
graph = runtime_graph()
|
||||
flow = next(node for node in graph.nodes if node.id == "flow")
|
||||
flow.config["warning_policy"] = "continue"
|
||||
flow.config["failure_policy"] = "fail"
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Automated Dataflow",
|
||||
graph=graph,
|
||||
execution_mode="automated",
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
instance, _replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="automated-dataflow",
|
||||
),
|
||||
)
|
||||
self.dataflow.fail("run:1")
|
||||
|
||||
changed = reconcile_instance(
|
||||
self.session,
|
||||
instance=instance,
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual("failed", instance.status)
|
||||
self.assertIsNone(instance.current_step_id)
|
||||
self.assertFalse(
|
||||
any(
|
||||
step.handoff.get("kind") == "dataflow_failure"
|
||||
for step in instance.steps
|
||||
)
|
||||
)
|
||||
|
||||
def test_native_bpmn_profile_runs_through_canonical_instance_state(self) -> None:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="BPMN governed review",
|
||||
graph=runtime_graph(),
|
||||
bpmn=BpmnRevisionInput(
|
||||
xml=NATIVE_BPMN,
|
||||
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
),
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
instance, replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="bpmn-request-1",
|
||||
input={"case_id": "case-bpmn"},
|
||||
),
|
||||
)
|
||||
|
||||
self.assertFalse(replayed)
|
||||
self.assertEqual("waiting", instance.status)
|
||||
waiting = instance_response(self.session, instance).steps[-1]
|
||||
self.assertEqual("workflow.activity", waiting.node_type)
|
||||
self.assertEqual("Review request", waiting.handoff["title"])
|
||||
|
||||
replay, was_replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="bpmn-request-1",
|
||||
input={"case_id": "case-bpmn"},
|
||||
),
|
||||
)
|
||||
self.assertTrue(was_replayed)
|
||||
self.assertEqual(instance.id, replay.id)
|
||||
|
||||
resolved = resolve_step(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=waiting.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowStepActionRequest(
|
||||
action="complete",
|
||||
comment="Reviewed.",
|
||||
evidence=["case:case-bpmn"],
|
||||
),
|
||||
)
|
||||
response = instance_response(self.session, resolved)
|
||||
self.assertEqual("completed", response.status)
|
||||
self.assertEqual(
|
||||
"workflow.instance.completed",
|
||||
response.events[-1].kind,
|
||||
)
|
||||
|
||||
def test_reconcile_completes_with_stable_dataflow_output_refs(self) -> None:
|
||||
instance = self._start()
|
||||
self.dataflow.finish("run:1")
|
||||
|
||||
changed = reconcile_instance(
|
||||
self.session,
|
||||
instance=instance,
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
actor_id="account-1",
|
||||
)
|
||||
response = instance_response(self.session, instance)
|
||||
|
||||
self.assertTrue(changed)
|
||||
self.assertEqual("completed", response.status)
|
||||
flow_output = response.context["steps"]["flow"]
|
||||
self.assertEqual("publication:1", flow_output["output_publication_ref"])
|
||||
self.assertEqual("datasource:1", flow_output["output_datasource_ref"])
|
||||
self.assertEqual(
|
||||
"materialization:1",
|
||||
flow_output["output_materialization_ref"],
|
||||
)
|
||||
self.assertEqual("workflow.instance.completed", response.events[-1].kind)
|
||||
|
||||
def test_warning_requires_review_and_approve_resumes(self) -> None:
|
||||
instance = self._start()
|
||||
self.dataflow.finish(
|
||||
"run:1",
|
||||
diagnostics=[
|
||||
{
|
||||
"severity": "warning",
|
||||
"code": "review.required",
|
||||
"message": "Verify unmatched records.",
|
||||
}
|
||||
],
|
||||
)
|
||||
reconcile_instance(
|
||||
self.session,
|
||||
instance=instance,
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
)
|
||||
step_id = str(instance.current_step_id)
|
||||
|
||||
self.assertEqual(
|
||||
"review_required",
|
||||
instance_response(self.session, instance).steps[-1].handoff["state"],
|
||||
)
|
||||
resolved = resolve_step(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=step_id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowStepActionRequest(
|
||||
action="approve",
|
||||
comment="Evidence verified.",
|
||||
evidence=["publication:1"],
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual("completed", resolved.status)
|
||||
|
||||
def test_failure_can_retry_and_reject_invalid_actions(self) -> None:
|
||||
instance = self._start()
|
||||
self.dataflow.fail("run:1")
|
||||
reconcile_instance(
|
||||
self.session,
|
||||
instance=instance,
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
)
|
||||
step_id = str(instance.current_step_id)
|
||||
|
||||
with self.assertRaises(WorkflowConflictError):
|
||||
resolve_step(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=step_id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowStepActionRequest(action="approve"),
|
||||
)
|
||||
retried = resolve_step(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
step_id=step_id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
payload=WorkflowStepActionRequest(action="retry"),
|
||||
)
|
||||
|
||||
self.assertEqual("waiting", retried.status)
|
||||
self.assertEqual(2, len(self.dataflow.requests))
|
||||
self.assertEqual("run:2", retried.steps[-1].external_ref)
|
||||
self.assertEqual("superseded", retried.steps[-2].status)
|
||||
|
||||
def test_cancel_propagates_to_linked_dataflow(self) -> None:
|
||||
instance = self._start()
|
||||
|
||||
cancelled = cancel_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
instance_id=instance.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=self.registry,
|
||||
)
|
||||
|
||||
self.assertEqual("cancelled", cancelled.status)
|
||||
self.assertEqual(["run:1"], self.dataflow.cancelled)
|
||||
|
||||
def test_worker_rechecks_authorization_before_reconciling(self) -> None:
|
||||
instance = self._start()
|
||||
self.session.commit()
|
||||
self.dataflow.finish("run:1")
|
||||
automation = FakeAutomationProvider()
|
||||
worker = SqlWorkflowRuntimeWorker(
|
||||
registry=Registry(self.dataflow, automation),
|
||||
)
|
||||
|
||||
summary = worker.reconcile_pending(self.session)
|
||||
|
||||
self.assertEqual(1, summary["advanced"])
|
||||
self.assertEqual("completed", instance.status)
|
||||
self.assertEqual(1, len(automation.requests))
|
||||
self.assertEqual(
|
||||
"rechecked",
|
||||
instance.authorization_["last_resolution"]["status"],
|
||||
)
|
||||
|
||||
def test_worker_reconciles_pending_module_action(self) -> None:
|
||||
action = FakeActionProvider("pending", "completed")
|
||||
automation = FakeAutomationProvider()
|
||||
registry = Registry(
|
||||
self.dataflow,
|
||||
automation=automation,
|
||||
action=action,
|
||||
)
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="account-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Asynchronous action",
|
||||
graph=action_graph(),
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
)
|
||||
instance, _replayed = start_instance(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="account-1",
|
||||
principal=principal(),
|
||||
registry=registry,
|
||||
payload=WorkflowInstanceStartRequest(
|
||||
idempotency_key="pending-action",
|
||||
input={"case_id": "case-3"},
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
worker = SqlWorkflowRuntimeWorker(registry=registry)
|
||||
|
||||
summary = worker.reconcile_pending(self.session)
|
||||
|
||||
self.assertEqual(1, summary["advanced"])
|
||||
self.assertEqual("completed", instance.status)
|
||||
self.assertEqual(2, len(action.requests))
|
||||
self.assertEqual(
|
||||
action.requests[0].idempotency_key,
|
||||
action.requests[1].idempotency_key,
|
||||
)
|
||||
self.assertEqual(1, len(automation.requests))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_workflow_engine.backend.manifest import (
|
||||
DEFINITION_READ_SCOPE,
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
INSTANCE_START_SCOPE,
|
||||
get_manifest,
|
||||
)
|
||||
from govoplan_core.core.workflows import (
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowManifestTests(unittest.TestCase):
|
||||
def test_manifest_exposes_definition_contracts(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual(manifest.id, "workflow_engine")
|
||||
self.assertEqual(manifest.permission_namespace, "workflow")
|
||||
self.assertIn(
|
||||
"workflow.definition_graph",
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertIn(
|
||||
DEFINITION_READ_SCOPE,
|
||||
{item.scope for item in manifest.permissions},
|
||||
)
|
||||
self.assertIn(
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
{item.scope for item in manifest.permissions},
|
||||
)
|
||||
self.assertIn(
|
||||
INSTANCE_START_SCOPE,
|
||||
{item.scope for item in manifest.permissions},
|
||||
)
|
||||
self.assertIn(
|
||||
"workflow.runtime_worker",
|
||||
{item.name for item in manifest.provides_interfaces},
|
||||
)
|
||||
self.assertIn(
|
||||
CAPABILITY_WORKFLOW_RUNTIME_WORKER,
|
||||
manifest.capability_factories,
|
||||
)
|
||||
self.assertIsNone(manifest.frontend)
|
||||
self.assertEqual((), manifest.nav_items)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
self.assertEqual("workflow_engine", manifest.migration_spec.module_id)
|
||||
requirement = next(
|
||||
item
|
||||
for item in manifest.requires_interfaces
|
||||
if item.name == "dataflow.run_lifecycle"
|
||||
)
|
||||
self.assertTrue(requirement.optional)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from dataclasses import replace
|
||||
|
||||
from govoplan_workflow_engine.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class WorkflowMigrationTests(unittest.TestCase):
|
||||
def test_migration_creates_definition_tables_and_head(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-workflow-engine-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'workflow.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("workflow_engine",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"0b4e7c9a2d6f",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"workflow_definition_revisions",
|
||||
"workflow_definitions",
|
||||
"workflow_instance_events",
|
||||
"workflow_instance_steps",
|
||||
"workflow_instances",
|
||||
},
|
||||
{
|
||||
name
|
||||
for name in inspect(connection).get_table_names()
|
||||
if name.startswith("workflow_")
|
||||
},
|
||||
)
|
||||
revision_columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"workflow_definition_revisions"
|
||||
)
|
||||
}
|
||||
self.assertTrue(
|
||||
{
|
||||
"bpmn_xml",
|
||||
"bpmn_hash",
|
||||
"bpmn_adapter_id",
|
||||
"bpmn_adapter_version",
|
||||
"bpmn_runtime_kind",
|
||||
"bpmn_executable",
|
||||
"execution_mode",
|
||||
"view_id",
|
||||
"view_revision_id",
|
||||
}.issubset(revision_columns)
|
||||
)
|
||||
instance_columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"workflow_instances"
|
||||
)
|
||||
}
|
||||
self.assertIn("start_origin", instance_columns)
|
||||
definition_columns = {
|
||||
item["name"]
|
||||
for item in inspect(connection).get_columns(
|
||||
"workflow_definitions"
|
||||
)
|
||||
}
|
||||
self.assertIn(
|
||||
"standard_origin_module_id",
|
||||
definition_columns,
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
def test_existing_workflow_migration_head_transfers_without_replay(self) -> None:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="govoplan-workflow-engine-upgrade-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'workflow.db'}"
|
||||
engine_manifest = get_manifest()
|
||||
assert engine_manifest.migration_spec is not None
|
||||
legacy_revisions = Path(directory) / "legacy-revisions"
|
||||
legacy_revisions.mkdir()
|
||||
current_revisions = Path(
|
||||
engine_manifest.migration_spec.script_location or ""
|
||||
)
|
||||
for path in current_revisions.glob("*.py"):
|
||||
if path.name.startswith("0b4e7c9a2d6f_"):
|
||||
continue
|
||||
shutil.copy2(path, legacy_revisions / path.name)
|
||||
legacy_manifest = replace(
|
||||
engine_manifest,
|
||||
id="workflow",
|
||||
name="Workflow",
|
||||
permission_namespace=None,
|
||||
migration_spec=replace(
|
||||
engine_manifest.migration_spec,
|
||||
module_id="workflow",
|
||||
script_location=str(legacy_revisions),
|
||||
),
|
||||
)
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("workflow",),
|
||||
manifest_factories=(lambda: legacy_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
first_tables = set(inspect(engine).get_table_names())
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"f1b7d3e5a9c2",
|
||||
set(
|
||||
MigrationContext.configure(
|
||||
connection
|
||||
).get_current_heads()
|
||||
),
|
||||
)
|
||||
self.assertNotIn(
|
||||
"standard_origin_module_id",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspect(engine).get_columns(
|
||||
"workflow_definitions"
|
||||
)
|
||||
},
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
result = migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("workflow_engine",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
|
||||
self.assertIn("0b4e7c9a2d6f", result.current_revision or "")
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
self.assertEqual(first_tables, set(inspect(engine).get_table_names()))
|
||||
self.assertIn(
|
||||
"standard_origin_module_id",
|
||||
{
|
||||
item["name"]
|
||||
for item in inspect(engine).get_columns(
|
||||
"workflow_definitions"
|
||||
)
|
||||
},
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_workflow_engine.backend.node_library import (
|
||||
BPMN_NODE_TYPES,
|
||||
WORKFLOW_GRAPH_LIBRARY,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import WorkflowEdge, WorkflowGraph, WorkflowNode
|
||||
from govoplan_workflow_engine.backend.validation import validate_workflow_graph
|
||||
|
||||
|
||||
class WorkflowNodeLibraryTests(unittest.TestCase):
|
||||
def test_valid_workflow_graph(self) -> None:
|
||||
graph = WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(id="start", type="workflow.start.manual"),
|
||||
WorkflowNode(
|
||||
id="work",
|
||||
type="workflow.activity",
|
||||
config={"title": "Check submission"},
|
||||
),
|
||||
WorkflowNode(id="done", type="workflow.end.completed"),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="e1", source="start", target="work"),
|
||||
WorkflowEdge(id="e2", source="work", target="done"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(validate_workflow_graph(graph), ())
|
||||
|
||||
def test_correction_loop_is_allowed(self) -> None:
|
||||
graph = WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(id="start", type="workflow.start.manual"),
|
||||
WorkflowNode(
|
||||
id="work",
|
||||
type="workflow.activity",
|
||||
config={"title": "Prepare"},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="review",
|
||||
type="workflow.review",
|
||||
config={"title": "Review"},
|
||||
),
|
||||
WorkflowNode(id="done", type="workflow.end.completed"),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="e1", source="start", target="work"),
|
||||
WorkflowEdge(id="e2", source="work", target="review"),
|
||||
WorkflowEdge(
|
||||
id="e3",
|
||||
source="review",
|
||||
source_port="changes",
|
||||
target="work",
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="e4",
|
||||
source="review",
|
||||
source_port="approved",
|
||||
target="done",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertNotIn(
|
||||
"graph.cycle",
|
||||
{item.code for item in validate_workflow_graph(graph)},
|
||||
)
|
||||
|
||||
def test_constraints_and_required_configuration_are_reported(self) -> None:
|
||||
graph = WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(id="start-1", type="workflow.start.manual"),
|
||||
WorkflowNode(
|
||||
id="start-2",
|
||||
type="workflow.start.event",
|
||||
config={"event_type": ""},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="e1", source="start-1", target="start-2"),
|
||||
],
|
||||
)
|
||||
|
||||
diagnostics = validate_workflow_graph(graph)
|
||||
codes = {item.code for item in diagnostics}
|
||||
self.assertIn("graph.trigger_count", codes)
|
||||
self.assertIn("graph.outcome_count", codes)
|
||||
self.assertIn("node.config_required", codes)
|
||||
self.assertIn("node.outgoing_required", codes)
|
||||
|
||||
def test_library_has_domain_specific_cycle_policy(self) -> None:
|
||||
self.assertTrue(WORKFLOW_GRAPH_LIBRARY.constraints.allow_cycles)
|
||||
self.assertEqual(WORKFLOW_GRAPH_LIBRARY.id, "workflow")
|
||||
self.assertEqual("1.0.0", WORKFLOW_GRAPH_LIBRARY.version)
|
||||
activity = WORKFLOW_GRAPH_LIBRARY.node_type("workflow.activity")
|
||||
self.assertIn(
|
||||
"view_surface_ids",
|
||||
{field.id for field in activity.config_fields},
|
||||
)
|
||||
|
||||
def test_native_palette_uses_standard_bpmn_vocabulary(self) -> None:
|
||||
node_types = {item.type for item in BPMN_NODE_TYPES}
|
||||
|
||||
self.assertIn("bpmn.startEvent", node_types)
|
||||
self.assertIn("bpmn.userTask", node_types)
|
||||
self.assertIn("bpmn.exclusiveGateway", node_types)
|
||||
self.assertIn("bpmn.participant", node_types)
|
||||
self.assertIn("bpmn.textAnnotation", node_types)
|
||||
self.assertTrue(all(item.startswith("bpmn.") for item in node_types))
|
||||
|
||||
def test_bpmn_rejects_multiple_default_flows(self) -> None:
|
||||
graph = WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(id="start", type="bpmn.startEvent"),
|
||||
WorkflowNode(id="choice", type="bpmn.exclusiveGateway"),
|
||||
WorkflowNode(id="end-a", type="bpmn.endEvent"),
|
||||
WorkflowNode(id="end-b", type="bpmn.endEvent"),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="to-choice", source="start", target="choice"),
|
||||
WorkflowEdge(
|
||||
id="default-a",
|
||||
source="choice",
|
||||
target="end-a",
|
||||
config={"default": True},
|
||||
),
|
||||
WorkflowEdge(
|
||||
id="default-b",
|
||||
source="choice",
|
||||
target="end-b",
|
||||
config={"default": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
"bpmn.multiple_default_flows",
|
||||
{item.code for item in validate_workflow_graph(graph)},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,427 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow_engine.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.schemas import (
|
||||
BpmnRevisionInput,
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowPosition,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.service import (
|
||||
WorkflowBpmnValidationError,
|
||||
WorkflowConflictError,
|
||||
WorkflowNotFoundError,
|
||||
activate_definition,
|
||||
create_definition,
|
||||
delete_definition,
|
||||
get_definition,
|
||||
list_definition_revisions,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.bpmn_adapters import (
|
||||
INTERCHANGE_ADAPTER_ID,
|
||||
NATIVE_LINEAR_ADAPTER_ID,
|
||||
)
|
||||
from govoplan_workflow_engine.backend.bpmn import inspect_bpmn_xml
|
||||
from govoplan_workflow_engine.backend.bpmn_graph import NATIVE_BPMN_ADAPTER_ID
|
||||
try:
|
||||
from test_bpmn import BPMN, NATIVE_BPMN
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != "test_bpmn":
|
||||
raise
|
||||
from tests.test_bpmn import BPMN, NATIVE_BPMN
|
||||
|
||||
|
||||
def sample_graph(*, title: str = "Review request") -> WorkflowGraph:
|
||||
return WorkflowGraph(
|
||||
nodes=[
|
||||
WorkflowNode(
|
||||
id="start",
|
||||
type="workflow.start.manual",
|
||||
label="Start",
|
||||
position=WorkflowPosition(x=40, y=100),
|
||||
config={"input_schema_ref": ""},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="activity",
|
||||
type="workflow.activity",
|
||||
label="Review",
|
||||
position=WorkflowPosition(x=280, y=100),
|
||||
config={
|
||||
"title": title,
|
||||
"instructions": "",
|
||||
"assignee": "",
|
||||
"due_after": "",
|
||||
},
|
||||
),
|
||||
WorkflowNode(
|
||||
id="complete",
|
||||
type="workflow.end.completed",
|
||||
label="Completed",
|
||||
position=WorkflowPosition(x=520, y=100),
|
||||
config={"output_mapping": {}},
|
||||
),
|
||||
],
|
||||
edges=[
|
||||
WorkflowEdge(id="start-activity", source="start", target="activity"),
|
||||
WorkflowEdge(id="activity-complete", source="activity", target="complete"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class WorkflowServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinition.__table__,
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
],
|
||||
)
|
||||
self.Session = sessionmaker(bind=self.engine)
|
||||
self.session: Session = self.Session()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
Base.metadata.drop_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
WorkflowDefinitionRevision.__table__,
|
||||
WorkflowDefinition.__table__,
|
||||
],
|
||||
)
|
||||
self.engine.dispose()
|
||||
|
||||
def _create(self, *, tenant_id: str = "tenant-1") -> WorkflowDefinition:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id=tenant_id,
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Monthly case handling",
|
||||
graph=sample_graph(),
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
return definition
|
||||
|
||||
def test_create_update_and_activate_pin_immutable_revisions(self) -> None:
|
||||
definition = self._create()
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Monthly case handling",
|
||||
graph=sample_graph(title="Review corrected request"),
|
||||
expected_revision=1,
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
revision=1,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
revisions = list_definition_revisions(
|
||||
self.session,
|
||||
definition=updated,
|
||||
)
|
||||
self.assertEqual(2, updated.current_revision)
|
||||
self.assertEqual(1, updated.active_revision)
|
||||
self.assertEqual("active", updated.status)
|
||||
self.assertEqual([2, 1], [item.revision for item in revisions])
|
||||
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
|
||||
historical = next(item for item in revisions if item.revision == 1)
|
||||
self.assertEqual(
|
||||
"Review request",
|
||||
historical.graph["nodes"][1]["config"]["title"],
|
||||
)
|
||||
|
||||
def test_metadata_update_does_not_create_graph_revision(self) -> None:
|
||||
definition = self._create()
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Renamed workflow",
|
||||
description="Updated metadata only",
|
||||
graph=sample_graph(),
|
||||
metadata={"owner": "finance"},
|
||||
expected_revision=1,
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual(1, updated.current_revision)
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
list(
|
||||
self.session.scalars(
|
||||
select(WorkflowDefinitionRevision).where(
|
||||
WorkflowDefinitionRevision.definition_id
|
||||
== definition.id
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_execution_mode_and_view_pin_are_immutable_revision_content(
|
||||
self,
|
||||
) -> None:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Guided review",
|
||||
graph=sample_graph(),
|
||||
execution_mode="guided",
|
||||
view_id="view-1",
|
||||
view_revision_id="view-revision-1",
|
||||
),
|
||||
)
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Guided review",
|
||||
graph=sample_graph(),
|
||||
expected_revision=1,
|
||||
execution_mode="hybrid",
|
||||
view_id="view-1",
|
||||
view_revision_id="view-revision-1",
|
||||
),
|
||||
)
|
||||
|
||||
revisions = list_definition_revisions(
|
||||
self.session,
|
||||
definition=updated,
|
||||
)
|
||||
self.assertEqual(2, updated.current_revision)
|
||||
self.assertEqual("hybrid", revisions[0].execution_mode)
|
||||
self.assertEqual("guided", revisions[1].execution_mode)
|
||||
self.assertEqual("view-revision-1", revisions[1].view_revision_id)
|
||||
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
|
||||
|
||||
def test_automated_mode_rejects_human_handoff_paths(self) -> None:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Invalid automation",
|
||||
graph=sample_graph(),
|
||||
execution_mode="automated",
|
||||
allow_automation=True,
|
||||
),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
WorkflowConflictError,
|
||||
"human handoff paths",
|
||||
):
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-1",
|
||||
)
|
||||
|
||||
def test_stale_update_and_cross_tenant_access_are_rejected(self) -> None:
|
||||
definition = self._create()
|
||||
with self.assertRaises(WorkflowConflictError):
|
||||
update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="Stale",
|
||||
graph=sample_graph(),
|
||||
expected_revision=2,
|
||||
),
|
||||
)
|
||||
with self.assertRaises(WorkflowNotFoundError):
|
||||
get_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-2",
|
||||
definition_id=definition.id,
|
||||
)
|
||||
|
||||
def test_soft_delete_preserves_revisions_and_hides_definition(self) -> None:
|
||||
definition = self._create()
|
||||
delete_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
self.assertEqual([], list_definitions(self.session, tenant_id="tenant-1"))
|
||||
self.assertEqual(
|
||||
1,
|
||||
len(
|
||||
list(
|
||||
self.session.scalars(
|
||||
select(WorkflowDefinitionRevision).where(
|
||||
WorkflowDefinitionRevision.definition_id
|
||||
== definition.id
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_bpmn_xml_and_adapter_are_pinned_to_immutable_revisions(self) -> None:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="BPMN review",
|
||||
graph=sample_graph(),
|
||||
bpmn=BpmnRevisionInput(
|
||||
xml=NATIVE_BPMN,
|
||||
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
adapter_version="1.0.0",
|
||||
),
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
first = list_definition_revisions(
|
||||
self.session,
|
||||
definition=definition,
|
||||
)[0]
|
||||
self.assertTrue(inspect_bpmn_xml(first.bpmn_xml or "").valid_xml)
|
||||
self.assertEqual(NATIVE_BPMN_ADAPTER_ID, first.bpmn_adapter_id)
|
||||
self.assertEqual("1.0.0", first.bpmn_adapter_version)
|
||||
self.assertEqual("native_graph", first.bpmn_runtime_kind)
|
||||
self.assertEqual(
|
||||
"Review request",
|
||||
first.graph["nodes"][1]["config"]["title"],
|
||||
)
|
||||
|
||||
changed_xml = NATIVE_BPMN.replace(
|
||||
"Review request",
|
||||
"Review corrected request",
|
||||
)
|
||||
updated = update_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
payload=WorkflowDefinitionUpdateRequest(
|
||||
name="BPMN review",
|
||||
graph=sample_graph(),
|
||||
bpmn=BpmnRevisionInput(
|
||||
xml=changed_xml,
|
||||
adapter_id=NATIVE_LINEAR_ADAPTER_ID,
|
||||
),
|
||||
expected_revision=1,
|
||||
),
|
||||
)
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-2",
|
||||
revision=2,
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
revisions = list_definition_revisions(
|
||||
self.session,
|
||||
definition=updated,
|
||||
)
|
||||
self.assertEqual(2, updated.current_revision)
|
||||
self.assertEqual(2, updated.active_revision)
|
||||
self.assertIn("Review corrected request", revisions[0].bpmn_xml or "")
|
||||
self.assertNotEqual(revisions[0].content_hash, revisions[1].content_hash)
|
||||
|
||||
def test_model_only_bpmn_revision_fails_closed_on_activation(self) -> None:
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Interchange model",
|
||||
graph=sample_graph(),
|
||||
bpmn=BpmnRevisionInput(
|
||||
xml=BPMN,
|
||||
adapter_id=INTERCHANGE_ADAPTER_ID,
|
||||
),
|
||||
),
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
WorkflowBpmnValidationError,
|
||||
"exclusive gateway",
|
||||
):
|
||||
activate_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
definition_id=definition.id,
|
||||
actor_id="user-1",
|
||||
)
|
||||
|
||||
def test_interchange_revision_preserves_extension_xml_exactly(self) -> None:
|
||||
xml = (
|
||||
Path(__file__).parent
|
||||
/ "fixtures"
|
||||
/ "bpmn"
|
||||
/ "process.bpmn"
|
||||
).read_text(encoding="utf-8")
|
||||
definition = create_definition(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
actor_id="user-1",
|
||||
payload=WorkflowDefinitionCreateRequest(
|
||||
name="Extended interchange model",
|
||||
graph=sample_graph(),
|
||||
bpmn=BpmnRevisionInput(
|
||||
xml=xml,
|
||||
adapter_id=INTERCHANGE_ADAPTER_ID,
|
||||
adapter_version="1.0.0",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
revision = list_definition_revisions(
|
||||
self.session,
|
||||
definition=definition,
|
||||
)[0]
|
||||
self.assertTrue(inspect_bpmn_xml(revision.bpmn_xml or "").valid_xml)
|
||||
self.assertIn("fixture revision=\"1\"", revision.bpmn_xml or "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user