# 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"