gateway/tests/unit/nodeDefinitions/test_trustee_schema_compliance.py
2026-04-25 01:13:01 +02:00

188 lines
7.8 KiB
Python

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