gateway/tests/integration/automation2/test_pick_not_push_migration_v2.py
2026-04-25 01:13:01 +02:00

189 lines
7.1 KiB
Python

# Copyright (c) 2025 Patrick Motsch
"""
Phase-5 Schicht-4 integration test (T11): the typed-bindings pipeline must
produce identical action-call parameters whether a workflow stores
``featureInstanceId`` as a legacy raw UUID or as a typed
``FeatureInstanceRef`` envelope.
The pipeline under test::
saved graph
-> materializeFeatureInstanceRefs (Phase-5, this test)
-> materializeConnectionRefs (existing pick-not-push helper)
-> resolveParameterReferences (typed bindings + envelope unwrap)
-> action params (what the action implementation would receive)
This is the integration counterpart to the focused unit tests in
``tests/unit/workflows/test_featureInstanceRefMigration.py``.
Plan: ``wiki/c-work/1-plan/2026-04-typed-action-architecture.md``.
"""
from __future__ import annotations
import copy
from typing import Any, Dict
import pytest
from modules.workflows.automation2.featureInstanceRefMigration import (
materializeFeatureInstanceRefs,
)
from modules.workflows.automation2.graphUtils import resolveParameterReferences
from modules.workflows.automation2.pickNotPushMigration import materializeConnectionRefs
_TRUSTEE_INSTANCE_UUID = "f1e2d3c4-b5a6-7890-1234-567890abcdef"
def _resolveActionParams(graph: Dict[str, Any], nodeId: str) -> Dict[str, Any]:
"""Apply the full Schicht-4 pipeline and return the resolved action params
that ``ActionNodeExecutor`` would forward to ``ActionExecutor.executeAction``."""
g = materializeFeatureInstanceRefs(graph)
g = materializeConnectionRefs(g)
targetNode = next(n for n in g["nodes"] if n["id"] == nodeId)
rawParams = dict(targetNode.get("parameters") or {})
return resolveParameterReferences(rawParams, nodeOutputs={})
def _legacyTrusteeGraph() -> Dict[str, Any]:
"""Trustee Spesenbelege-shape graph with raw UUIDs (pre-migration)."""
return {
"nodes": [
{"id": "n1", "type": "trigger.manual", "parameters": {}},
{
"id": "n5",
"type": "trustee.extractFromFiles",
"parameters": {
"featureInstanceId": _TRUSTEE_INSTANCE_UUID,
"prompt": "extract expenses",
},
},
{
"id": "n6",
"type": "trustee.processDocuments",
"parameters": {
"featureInstanceId": _TRUSTEE_INSTANCE_UUID,
"documentList": {
"type": "ref",
"nodeId": "n5",
"path": ["documents"],
},
},
},
{
"id": "n7",
"type": "trustee.syncToAccounting",
"parameters": {
"featureInstanceId": _TRUSTEE_INSTANCE_UUID,
"documentList": {
"type": "ref",
"nodeId": "n6",
"path": ["documents"],
},
},
},
],
"connections": [
{"source": "n1", "target": "n5"},
{"source": "n5", "target": "n6"},
{"source": "n6", "target": "n7"},
],
}
def _migratedTrusteeGraph() -> Dict[str, Any]:
"""The same graph but already in the migrated (typed envelope) shape."""
g = _legacyTrusteeGraph()
envelope = {
"$type": "FeatureInstanceRef",
"id": _TRUSTEE_INSTANCE_UUID,
"featureCode": "trustee",
}
for node in g["nodes"]:
if node.get("type", "").startswith("trustee."):
node["parameters"]["featureInstanceId"] = copy.deepcopy(envelope)
return g
# ---------------------------------------------------------------------------
# Round-trip: legacy + migrated graphs produce identical action params
# ---------------------------------------------------------------------------
class TestTrusteeBindingsPipeline:
@pytest.mark.parametrize("nodeId", ["n5", "n6", "n7"])
def test_legacyAndMigratedGraphsResolveToSameFeatureInstanceId(self, nodeId):
legacyParams = _resolveActionParams(_legacyTrusteeGraph(), nodeId)
migratedParams = _resolveActionParams(_migratedTrusteeGraph(), nodeId)
assert legacyParams["featureInstanceId"] == _TRUSTEE_INSTANCE_UUID
assert migratedParams["featureInstanceId"] == _TRUSTEE_INSTANCE_UUID
assert legacyParams == migratedParams
def test_legacyGraphIsConvertedToTypedEnvelopeInPlaceOfRawUuid(self):
legacy = _legacyTrusteeGraph()
migrated = materializeFeatureInstanceRefs(legacy)
for node in migrated["nodes"]:
if not node.get("type", "").startswith("trustee."):
continue
param = node["parameters"]["featureInstanceId"]
assert isinstance(param, dict), f"node {node['id']} not migrated"
assert param["$type"] == "FeatureInstanceRef"
assert param["id"] == _TRUSTEE_INSTANCE_UUID
assert param["featureCode"] == "trustee"
def test_migrationIsIdempotentAcrossPipeline(self):
once = materializeFeatureInstanceRefs(_legacyTrusteeGraph())
twice = materializeFeatureInstanceRefs(once)
assert once == twice
def test_otherParamsArePreservedAcrossMigration(self):
legacy = _legacyTrusteeGraph()
migrated = materializeFeatureInstanceRefs(legacy)
n5 = next(n for n in migrated["nodes"] if n["id"] == "n5")
assert n5["parameters"]["prompt"] == "extract expenses"
n6 = next(n for n in migrated["nodes"] if n["id"] == "n6")
# documentList DataRef must survive untouched (only the
# featureInstanceId key is rewritten).
assert n6["parameters"]["documentList"] == {
"type": "ref",
"nodeId": "n5",
"path": ["documents"],
}
# ---------------------------------------------------------------------------
# Cross-feature: same migration handles redmine / clickup / sharepoint
# ---------------------------------------------------------------------------
class TestCrossFeatureMigration:
@pytest.mark.parametrize(
"nodeType,expectedCode",
[
("redmine.createIssue", "redmine"),
("clickup.createTask", "clickup"),
("sharepoint.listFiles", "sharepoint"),
],
)
def test_nonTrusteeNodesAreMigratedWithCorrectFeatureCode(
self, nodeType, expectedCode
):
graph = {
"nodes": [
{
"id": "n",
"type": nodeType,
"parameters": {"featureInstanceId": "uuid-x"},
}
]
}
out = materializeFeatureInstanceRefs(graph)
env = out["nodes"][0]["parameters"]["featureInstanceId"]
assert env == {
"$type": "FeatureInstanceRef",
"id": "uuid-x",
"featureCode": expectedCode,
}
# And the resolver still hands back the raw UUID for legacy actions.
resolved = resolveParameterReferences(env, nodeOutputs={})
assert resolved == "uuid-x"