feat: persist and edit versioned workflow definitions
This commit is contained in:
@@ -26,6 +26,17 @@ class WorkflowManifestTests(unittest.TestCase):
|
||||
DEFINITION_WRITE_SCOPE,
|
||||
{item.scope for item in manifest.permissions},
|
||||
)
|
||||
self.assertEqual(
|
||||
"@govoplan/workflow-webui",
|
||||
manifest.frontend.package_name if manifest.frontend else None,
|
||||
)
|
||||
self.assertIsNotNone(manifest.migration_spec)
|
||||
requirement = next(
|
||||
item
|
||||
for item in manifest.requires_interfaces
|
||||
if item.name == "dataflow.run_lifecycle"
|
||||
)
|
||||
self.assertTrue(requirement.optional)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
48
tests/test_migrations.py
Normal file
48
tests/test_migrations.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
from govoplan_core.db.migrations import migrate_database
|
||||
from govoplan_workflow.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-migration-"
|
||||
) as directory:
|
||||
url = f"sqlite:///{Path(directory) / 'workflow.db'}"
|
||||
migrate_database(
|
||||
database_url=url,
|
||||
enabled_modules=("workflow",),
|
||||
manifest_factories=(get_manifest,),
|
||||
)
|
||||
engine = create_engine(url)
|
||||
try:
|
||||
with engine.connect() as connection:
|
||||
self.assertIn(
|
||||
"a7c4e2f9b1d3",
|
||||
set(MigrationContext.configure(connection).get_current_heads()),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
"workflow_definition_revisions",
|
||||
"workflow_definitions",
|
||||
},
|
||||
{
|
||||
name
|
||||
for name in inspect(connection).get_table_names()
|
||||
if name.startswith("workflow_")
|
||||
},
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
225
tests/test_service.py
Normal file
225
tests/test_service.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
from govoplan_workflow.backend.schemas import (
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
WorkflowEdge,
|
||||
WorkflowGraph,
|
||||
WorkflowNode,
|
||||
WorkflowPosition,
|
||||
)
|
||||
from govoplan_workflow.backend.service import (
|
||||
WorkflowConflictError,
|
||||
WorkflowNotFoundError,
|
||||
activate_definition,
|
||||
create_definition,
|
||||
delete_definition,
|
||||
get_definition,
|
||||
list_definition_revisions,
|
||||
list_definitions,
|
||||
update_definition,
|
||||
)
|
||||
|
||||
|
||||
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_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
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user