Files
govoplan-workflow-engine/tests/test_service.py
T

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()