# Copyright (c) 2025 Patrick Motsch """Trustee node schema-compliance under the Pick-not-Push typed port system. Verifies that: - All three trustee actions (extractFromFiles, processDocuments, syncToAccounting) declare ``ActionResult`` as output, matching what the Python implementations actually return at runtime (``ActionResult.isSuccess(documents=[...])``). - processDocuments / syncToAccounting accept ``ActionResult`` (the producer schema) plus ``DocumentList`` and ``Transit`` for back-compat. - The ``documentList`` parameter is required, typed ``List[ActionDocument]`` (the concrete shape consumed by ``_resolveDocumentList``) and rendered via the dataRef picker so the user can bind it to ``upstream → documents``. - The end-to-end Trustee pipeline graph (extract -> process -> sync) passes hard port-compat validation (validateGraph). - actionNodeExecutor produces canonical ``documents`` field — no legacy ``documentList`` alias — so that DataRef path=['documents'] is the single source of truth. """ import inspect from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG from modules.workflows.automation2.executors import actionNodeExecutor as _actionExec from modules.workflows.automation2.graphUtils import validateGraph def _node(nodeId: str) -> dict: return next(n for n in STATIC_NODE_TYPES if n["id"] == nodeId) def test_extractFromFiles_outputs_ActionResult(): """Runtime returns ActionResult.isSuccess(documents=[...]) — see actions/extractFromFiles.py. The adapter must declare the same.""" n = _node("trustee.extractFromFiles") assert n["outputPorts"][0]["schema"] == "ActionResult" def test_processDocuments_outputs_ActionResult(): n = _node("trustee.processDocuments") assert n["outputPorts"][0]["schema"] == "ActionResult" def test_syncToAccounting_outputs_ActionResult(): n = _node("trustee.syncToAccounting") assert n["outputPorts"][0]["schema"] == "ActionResult" def test_processDocuments_accepts_ActionResult_and_legacy(): """processDocuments must accept ActionResult (the new producer schema for extractFromFiles) plus DocumentList / Transit for back-compat.""" n = _node("trustee.processDocuments") accepts = n["inputPorts"][0]["accepts"] assert "ActionResult" in accepts assert "DocumentList" in accepts assert "Transit" in accepts assert "UdmDocument" not in accepts, ( "UdmDocument was dropped from accepts during the Pick-not-Push schema cleanup." ) def test_syncToAccounting_accepts_ActionResult_and_legacy(): n = _node("trustee.syncToAccounting") accepts = n["inputPorts"][0]["accepts"] assert "ActionResult" in accepts assert "DocumentList" in accepts assert "Transit" in accepts def test_processDocuments_documentList_param_typed_required_dataRef(): """documentList is a Pick-not-Push DataRef parameter — must be visible and typed exactly like the producer field (``ActionResult.documents`` is ``List[ActionDocument]``) so DataPicker's strict-filter accepts it. """ params = {p["name"]: p for p in _node("trustee.processDocuments")["parameters"]} p = params["documentList"] assert p["type"] == "List[ActionDocument]", ( "documentList must declare the concrete producer type so the DataPicker " "strict-filter resolves upstream ActionResult.documents as compatible." ) assert p["required"] is True assert p["frontendType"] == "dataRef", ( "documentList must use the dataRef renderer so the binding is visible" ) def test_syncToAccounting_documentList_param_typed_required_dataRef(): params = {p["name"]: p for p in _node("trustee.syncToAccounting")["parameters"]} p = params["documentList"] assert p["type"] == "List[ActionDocument]", ( "documentList must declare the concrete producer type so the DataPicker " "strict-filter resolves upstream ActionResult.documents as compatible." ) assert p["required"] is True assert p["frontendType"] == "dataRef", ( "documentList must use the dataRef renderer so the binding is visible" ) def test_trustee_pipeline_graph_passes_hard_port_validation(): """End-to-end pipeline: trigger.manual -> extract -> process -> sync. Mirrors what frontend_nyla/.../trusteePipelineGraph.ts builds for _buildScanUploadGraph. Port-compat must hold without warnings. """ graph = { "nodes": [ {"id": "trigger-manual", "type": "trigger.manual", "parameters": {}}, { "id": "extract", "type": "trustee.extractFromFiles", "parameters": { "fileIds": ["f1"], "featureInstanceId": "inst-1", "prompt": "", }, }, { "id": "process", "type": "trustee.processDocuments", "parameters": { "documentList": {"type": "ref", "nodeId": "extract", "path": ["documents"]}, "featureInstanceId": "inst-1", }, }, { "id": "sync", "type": "trustee.syncToAccounting", "parameters": { "documentList": {"type": "ref", "nodeId": "process", "path": ["documents"]}, "featureInstanceId": "inst-1", }, }, ], "connections": [ {"source": "trigger-manual", "sourceOutput": 0, "target": "extract", "targetInput": 0}, {"source": "extract", "sourceOutput": 0, "target": "process", "targetInput": 0}, {"source": "process", "sourceOutput": 0, "target": "sync", "targetInput": 0}, ], } nodeTypeIds = {n["id"] for n in STATIC_NODE_TYPES} errors = validateGraph(graph, nodeTypeIds) portMismatches = [e for e in errors if "Port mismatch" in e] assert not portMismatches, f"Trustee pipeline must be port-compatible: {portMismatches}" def test_catalog_ActionResult_exposes_documents_field(): """Without ``documents`` on the ActionResult schema the DataPicker cannot surface the canonical list-of-documents path that every downstream node (processDocuments, syncToAccounting, AI consumers, ...) needs to bind to. """ schema = PORT_TYPE_CATALOG.get("ActionResult") assert schema is not None fieldNames = {f.name for f in schema.fields} assert "documents" in fieldNames, ( "ActionResult.documents must be in PORT_TYPE_CATALOG so the frontend " "DataPicker can offer it as a bindable path." ) def test_catalog_ActionDocument_is_registered(): """ActionResult.documents is List[ActionDocument]; the inner schema must be registered so the picker can drill down to ``documents → * → documentName``. """ schema = PORT_TYPE_CATALOG.get("ActionDocument") assert schema is not None fieldNames = {f.name for f in schema.fields} assert {"documentName", "documentData", "mimeType"}.issubset(fieldNames), ( "ActionDocument schema must mirror datamodelChat.ActionDocument." ) def test_actionNodeExecutor_does_not_emit_legacy_documentList_alias(): """Source-code assertion: out dict in execute() must not write documentList alias. Pick-not-Push canonicalises on ``documents``. Removing the alias prevents DataRefs from drifting back to the legacy field name. """ src = inspect.getsource(_actionExec) assert '"documentList": docsList' not in src, ( "Legacy alias ``documentList`` must be removed from actionNodeExecutor " "out-dict (use canonical ``documents`` only — see issues.md " "'Trustee Schema-Compliance')." ) assert '"documents": docsList' in src, ( "Canonical ``documents`` field missing from actionNodeExecutor out-dict." )