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

339 lines
11 KiB
Python

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