428 lines
14 KiB
Python
428 lines
14 KiB
Python
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()
|