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

352 lines
13 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Tests for the Schicht-3 Adapter Validator (Phase 3).
Validates the 5 drift rules between Editor-Node Adapters and the
Schicht-2 Actions they bind to:
Rule 1: every userParams.actionArg exists in the Action
Rule 2: every required Action arg is covered (userParams or contextParams)
Rule 3: every Action parameter type exists in PORT_TYPE_CATALOG
Rule 4: Action outputType exists in PORT_TYPE_CATALOG
Rule 5: every Action with dynamicMode=False has an Editor adapter
Plus a healthy-state test that runs the validator against the live
STATIC_NODE_TYPES + every shipping Method instance, and asserts no drift.
"""
from __future__ import annotations
import importlib
import sys
import types
import pytest
from modules.datamodels.datamodelWorkflowActions import (
WorkflowActionDefinition,
WorkflowActionParameter,
)
from modules.features.graphicalEditor.adapterValidator import (
AdapterValidationReport,
_buildActionsRegistryFromMethods,
_formatAdapterReport,
_validateAdapterAgainstAction,
_validateAllAdapters,
)
from modules.features.graphicalEditor.nodeAdapter import (
NodeAdapter,
UserParamMapping,
)
from modules.shared.frontendTypes import FrontendType
# ---------------------------------------------------------------------------
# Test factories
# ---------------------------------------------------------------------------
def _makeParam(typeStr: str, *, required: bool = False, **kwargs) -> WorkflowActionParameter:
defaults = {
"name": "p",
"type": typeStr,
"frontendType": FrontendType.TEXT,
"required": required,
"description": "",
}
defaults.update(kwargs)
return WorkflowActionParameter(**defaults)
def _makeAction(
actionId: str = "trustee.processDocuments",
parameters: dict | None = None,
outputType: str = "TrusteeProcessResult",
dynamicMode: bool = False,
) -> WorkflowActionDefinition:
return WorkflowActionDefinition(
actionId=actionId,
description="t",
parameters=parameters or {},
outputType=outputType,
dynamicMode=dynamicMode,
execute=lambda *a, **k: None,
)
def _makeAdapter(
*,
userArgs: list[str] | None = None,
contextArgs: list[str] | None = None,
) -> NodeAdapter:
return NodeAdapter(
nodeId="trustee.processDocuments",
bindsAction="trustee.processDocuments",
category="trustee",
label="Verarbeiten",
description="...",
userParams=[UserParamMapping(actionArg=a) for a in (userArgs or [])],
contextParams={k: f"$session.{k}" for k in (contextArgs or [])},
)
# ---------------------------------------------------------------------------
# Per-rule unit tests
# ---------------------------------------------------------------------------
class TestRule1_UserParamArgExistsInAction:
def test_okWhenAllArgsExist(self):
action = _makeAction(parameters={
"documentList": _makeParam("DocumentList", required=True),
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True),
})
adapter = _makeAdapter(userArgs=["documentList", "featureInstanceId"])
report = _validateAdapterAgainstAction(adapter, action)
assert report.isHealthy, report.errors
def test_failsWhenAdapterReferencesUnknownArg(self):
action = _makeAction(parameters={"documentList": _makeParam("DocumentList", required=True),
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True)})
adapter = _makeAdapter(userArgs=["documentList", "featureInstanceId", "ghostArg"])
report = _validateAdapterAgainstAction(adapter, action)
assert any("ghostArg" in e for e in report.errors)
class TestRule2_RequiredArgsCovered:
def test_failsWhenRequiredArgMissing(self):
action = _makeAction(parameters={
"documentList": _makeParam("DocumentList", required=True),
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True),
})
adapter = _makeAdapter(userArgs=["documentList"]) # missing featureInstanceId
report = _validateAdapterAgainstAction(adapter, action)
assert any("featureInstanceId" in e for e in report.errors)
def test_okWhenRequiredArgInContext(self):
action = _makeAction(parameters={
"documentList": _makeParam("DocumentList", required=True),
"mandateId": _makeParam("str", required=True),
})
adapter = _makeAdapter(userArgs=["documentList"], contextArgs=["mandateId"])
report = _validateAdapterAgainstAction(adapter, action)
assert report.isHealthy, report.errors
def test_optionalArgMayBeUnset(self):
action = _makeAction(parameters={
"documentList": _makeParam("DocumentList", required=True),
"prompt": _makeParam("str", required=False),
})
adapter = _makeAdapter(userArgs=["documentList"])
report = _validateAdapterAgainstAction(adapter, action)
assert report.isHealthy, report.errors
class TestRule3_ActionParamTypesInCatalog:
def test_failsForUnknownType(self):
action = _makeAction(parameters={"documentList": _makeParam("Foobar", required=True)})
adapter = _makeAdapter(userArgs=["documentList"])
report = _validateAdapterAgainstAction(adapter, action)
assert any("Foobar" in e for e in report.errors)
class TestRule4_OutputTypeInCatalog:
def test_failsForUnknownOutputType(self):
action = _makeAction(outputType="Nonsense")
adapter = _makeAdapter()
report = _validateAdapterAgainstAction(adapter, action)
assert any("Nonsense" in e for e in report.errors)
def test_okForActionResult(self):
action = _makeAction(outputType="ActionResult")
adapter = _makeAdapter()
report = _validateAdapterAgainstAction(adapter, action)
assert report.isHealthy, report.errors
class TestRule5_OrphanActionsAcrossRegistry:
def test_warnsForActionWithoutAdapter(self):
action = _makeAction(actionId="trustee.queryData")
registry = {"trustee": {"queryData": action}}
report = _validateAllAdapters([], registry)
assert any("trustee.queryData" in w for w in report.warnings)
def test_dynamicModeActionDoesNotWarn(self):
action = _makeAction(actionId="trustee.queryData", dynamicMode=True)
registry = {"trustee": {"queryData": action}}
report = _validateAllAdapters([], registry)
assert report.warnings == []
# ---------------------------------------------------------------------------
# Aggregator + report formatter
# ---------------------------------------------------------------------------
class TestValidateAllAdapters:
def test_passesWithFullCoverage(self):
node = {
"id": "trustee.processDocuments",
"category": "trustee",
"label": "X", "description": "Y",
"parameters": [
{"name": "documentList", "type": "DocumentList"},
{"name": "featureInstanceId", "type": "FeatureInstanceRef"},
],
"inputs": 1, "outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList"]}},
"_method": "trustee", "_action": "processDocuments",
}
action = _makeAction(parameters={
"documentList": _makeParam("DocumentList", required=True),
"featureInstanceId": _makeParam("FeatureInstanceRef", required=True),
})
registry = {"trustee": {"processDocuments": action}}
report = _validateAllAdapters([node], registry)
assert report.isHealthy, report.errors
def test_reportsMissingAction(self):
node = {
"id": "trustee.processDocuments",
"_method": "trustee", "_action": "ghostAction",
"parameters": [], "inputs": 0,
}
report = _validateAllAdapters([node], {"trustee": {}})
assert any("ghostAction" in e for e in report.errors)
class TestFormatReport:
def test_healthy(self):
out = _formatAdapterReport(AdapterValidationReport())
assert "healthy" in out.lower()
def test_withErrorsAndWarnings(self):
rep = AdapterValidationReport(errors=["e1"], warnings=["w1"])
out = _formatAdapterReport(rep)
assert "ERROR" in out and "WARN" in out
# ---------------------------------------------------------------------------
# Healthy-state: live methods + STATIC_NODE_TYPES
# ---------------------------------------------------------------------------
class _NullRbac:
def getUserPermissions(self, **kwargs):
class _P:
view = read = create = update = delete = True
return _P()
class _StubServices:
def __init__(self):
self.rbac = _NullRbac()
self.user = type("U", (), {"id": "test-user", "roleLabels": []})()
self.mandateId = None
self.featureInstanceId = None
def _ensureOptionalDeps():
class _AnyAttrModule(types.ModuleType):
def __getattr__(self, name):
return type(name, (), {})
for name in ("aiohttp",):
if name not in sys.modules:
sys.modules[name] = _AnyAttrModule(name)
_LIVE_METHODS = [
("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee", "trustee"),
("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine", "redmine"),
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint", "sharepoint"),
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook", "outlook"),
("modules.workflows.methods.methodAi.methodAi", "MethodAi", "ai"),
("modules.workflows.methods.methodClickup.methodClickup", "MethodClickup", "clickup"),
("modules.workflows.methods.methodFile.methodFile", "MethodFile", "file"),
("modules.workflows.methods.methodContext.methodContext", "MethodContext", "context"),
]
def _instantiateLiveMethods() -> dict:
"""Best-effort instantiation of every shipping Method with stub services.
Returns {shortName: instance}. Methods that can't be instantiated in the
test env (missing dependencies) are skipped silently — Phase 2 has its
own healthy-state test that catches per-method drift.
"""
_ensureOptionalDeps()
out: dict = {}
for modulePath, className, shortName in _LIVE_METHODS:
try:
module = importlib.import_module(modulePath)
cls = getattr(module, className, None)
if cls is None:
continue
instance = cls(_StubServices())
out[shortName] = instance
except Exception:
continue
return out
# Snapshot of pre-Phase-3 drift discovered when the validator was first run
# against the live STATIC_NODE_TYPES + live Method registry.
#
# After Phase-4 Adapter-Drift-Cleanup (Plan #4) this set is intentionally
# empty: every Editor adapter must align cleanly with its Schicht-2 Action,
# and the regression net below now uses `assert report.errors == []`.
#
# History of removed drifts:
# wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md
#
# Rule: this set MUST stay empty. New drift => fix the adapter or the action,
# not the snapshot.
_KNOWN_ADAPTER_DRIFTS: frozenset[tuple[str, str]] = frozenset()
def _extractDriftKey(errorMessage: str) -> tuple[str, str] | None:
"""Parse a validator error message into a (nodeId, fieldName) drift key.
Recognises both rule-1 ("userParams.actionArg 'X' does not exist…") and
rule-2 ("required action arg 'X' is neither in userParams…") patterns.
"""
import re
m = re.search(
r"adapter '([^']+)' bindsAction '[^']+': userParams\.actionArg '([^']+)'",
errorMessage,
)
if m:
return (m.group(1), m.group(2))
m = re.search(
r"adapter '([^']+)' bindsAction '[^']+': required action arg '([^']+)'",
errorMessage,
)
if m:
return (m.group(1), m.group(2))
return None
def test_staticNodesHaveNoDriftAgainstLiveMethods():
"""Strict regression: every Editor adapter in STATIC_NODE_TYPES must align
with its Schicht-2 Action signature.
Phase 3 shipped the validator with a tracked drift snapshot
(`_KNOWN_ADAPTER_DRIFTS`); Phase 4 cleaned the backlog so the snapshot is
empty and we now demand zero errors. Any new drift fails immediately —
fix the adapter or the action, never the assertion.
History: wiki/c-work/4-done/2026-04-adapter-drift-cleanup.md
"""
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
instances = _instantiateLiveMethods()
if not instances:
pytest.skip("no methods could be instantiated in this test env")
registry = _buildActionsRegistryFromMethods(instances)
report = _validateAllAdapters(list(STATIC_NODE_TYPES), registry)
assert _KNOWN_ADAPTER_DRIFTS == frozenset(), (
"_KNOWN_ADAPTER_DRIFTS must stay empty after Phase-4 cleanup. "
"Do not add new entries — fix the drift instead."
)
assert report.errors == [], (
"Adapter↔Action drift detected:\n" + "\n".join(report.errors)
)