gateway/tests/unit/workflows/test_parameterValidation.py
2026-04-28 11:58:53 +02:00

206 lines
8.9 KiB
Python

# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Unit tests: universal action parameter validation + coercion.
This is the single source of truth for the action parameter contract:
every workflow action (called via the agent, the workflow graph, or REST)
runs through ``validateAndCoerceParameters`` before its body executes.
The tests pin three groups of behaviour:
1. **Required-parameter enforcement** — missing required params raise a
typed ``InvalidActionParameterError`` instead of an opaque downstream
error.
2. **Ref-payload normalization** — the agent's typed tool schema delivers
``FeatureInstanceRef`` as ``{id: ..., featureCode: ...}``, but actions
expect a bare UUID string. Collapsing happens here, not in N action
bodies.
3. **Primitive coercion** — ``"true"``/``"12"``/``"3.14"`` from JSON-shaped
payloads are coerced to bool/int/float, removing ad-hoc branches.
Unknown extra keys (e.g. ``parentOperationId``) flow through unchanged so
the executor can keep injecting cross-cutting context.
"""
from __future__ import annotations
import pytest
from modules.datamodels.datamodelWorkflowActions import (
WorkflowActionDefinition, WorkflowActionParameter,
)
from modules.shared.frontendTypes import FrontendType
from modules.workflows.processing.shared.parameterValidation import (
InvalidActionParameterError, validateAndCoerceParameters,
)
def _makeActionDef(actionId: str = "trustee.refreshAccountingData", **paramDefs) -> WorkflowActionDefinition:
"""Build a real WorkflowActionDefinition; we only care about parameters."""
parameters = {
name: WorkflowActionParameter(
name=name,
type=spec["type"],
frontendType=FrontendType.TEXT,
required=spec.get("required", False),
description=spec.get("description", ""),
)
for name, spec in paramDefs.items()
}
return WorkflowActionDefinition(
actionId=actionId,
description="Test action",
parameters=parameters,
execute=lambda *_a, **_kw: None,
)
class TestRequiredEnforcement:
def test_missingRequiredRaises(self):
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
with pytest.raises(InvalidActionParameterError) as excinfo:
validateAndCoerceParameters(actionDef, {})
assert excinfo.value.paramName == "featureInstanceId"
assert "required" in excinfo.value.reason.lower()
assert "trustee.refreshAccountingData.featureInstanceId" in str(excinfo.value)
def test_optionalMissingIsFine(self):
actionDef = _makeActionDef(
forceRefresh={"type": "bool", "required": False},
)
result = validateAndCoerceParameters(actionDef, {})
assert result == {}
def test_requiredNoneCountsAsMissing(self):
"""Explicit ``None`` for a required param is missing, not "unset"."""
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
with pytest.raises(InvalidActionParameterError):
validateAndCoerceParameters(actionDef, {"featureInstanceId": None})
class TestRefNormalization:
"""Trustee bug regression: agent passed `{id: ..., featureCode: ...}` and
Postgres failed with "can't adapt type 'dict'", which the connector
silently turned into "no record found"."""
def test_collapsesDictWithIdToString(self):
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
result = validateAndCoerceParameters(actionDef, {
"featureInstanceId": {
"id": "b7574103-f4a3-4894-8c23-74bd0d0e83a5",
"featureCode": "trustee",
"label": "Demo AG",
},
})
assert result["featureInstanceId"] == "b7574103-f4a3-4894-8c23-74bd0d0e83a5"
def test_passThroughString(self):
"""Workflow execution path passes a plain UUID; must not break."""
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
uuid = "b7574103-f4a3-4894-8c23-74bd0d0e83a5"
result = validateAndCoerceParameters(actionDef, {"featureInstanceId": uuid})
assert result["featureInstanceId"] == uuid
def test_dictWithoutIdRaises(self):
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
with pytest.raises(InvalidActionParameterError) as excinfo:
validateAndCoerceParameters(actionDef, {
"featureInstanceId": {"featureCode": "trustee", "label": "Demo"},
})
assert "id" in excinfo.value.reason
def test_otherDictTypeRaises(self):
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
with pytest.raises(InvalidActionParameterError):
validateAndCoerceParameters(actionDef, {"featureInstanceId": 12345})
def test_connectionRefAlsoCollapses(self):
"""Same logic applies to every Ref-Schema, not just FeatureInstanceRef."""
actionDef = _makeActionDef(
actionId="msft.readEmails",
connection={"type": "ConnectionRef", "required": True},
)
result = validateAndCoerceParameters(actionDef, {
"connection": {"id": "conn-uuid-123", "authority": "msft", "label": "Outlook"},
})
assert result["connection"] == "conn-uuid-123"
class TestPrimitiveCoercion:
def test_boolFromTrueString(self):
actionDef = _makeActionDef(forceRefresh={"type": "bool", "required": False})
result = validateAndCoerceParameters(actionDef, {"forceRefresh": "true"})
assert result["forceRefresh"] is True
def test_boolFromFalseString(self):
actionDef = _makeActionDef(forceRefresh={"type": "bool", "required": False})
result = validateAndCoerceParameters(actionDef, {"forceRefresh": "false"})
assert result["forceRefresh"] is False
def test_boolPassthrough(self):
actionDef = _makeActionDef(forceRefresh={"type": "bool", "required": False})
assert validateAndCoerceParameters(actionDef, {"forceRefresh": True})["forceRefresh"] is True
def test_boolBadValueRaises(self):
actionDef = _makeActionDef(forceRefresh={"type": "bool", "required": False})
with pytest.raises(InvalidActionParameterError):
validateAndCoerceParameters(actionDef, {"forceRefresh": "maybe"})
def test_intFromString(self):
actionDef = _makeActionDef(periodMonth={"type": "int", "required": False})
assert validateAndCoerceParameters(actionDef, {"periodMonth": "12"})["periodMonth"] == 12
def test_intBadValueRaises(self):
actionDef = _makeActionDef(periodMonth={"type": "int", "required": False})
with pytest.raises(InvalidActionParameterError):
validateAndCoerceParameters(actionDef, {"periodMonth": "twelve"})
def test_floatFromString(self):
actionDef = _makeActionDef(threshold={"type": "float", "required": False})
assert validateAndCoerceParameters(actionDef, {"threshold": "0.75"})["threshold"] == 0.75
class TestUnknownAndOtherTypes:
def test_unknownKeysPassThrough(self):
"""The executor injects parentOperationId, expectedDocumentFormats, etc.
Validation must not strip them."""
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
result = validateAndCoerceParameters(actionDef, {
"featureInstanceId": "uuid-123",
"parentOperationId": "action_xyz",
"expectedDocumentFormats": ["pdf", "txt"],
})
assert result["parentOperationId"] == "action_xyz"
assert result["expectedDocumentFormats"] == ["pdf", "txt"]
def test_strParamsAreUntouched(self):
actionDef = _makeActionDef(dateFrom={"type": "str", "required": False})
assert validateAndCoerceParameters(actionDef, {"dateFrom": "2025-01-01"})["dateFrom"] == "2025-01-01"
def test_listParamsAreUntouched(self):
actionDef = _makeActionDef(documentList={"type": "List[ActionDocument]", "required": False})
docs = [{"name": "a"}, {"name": "b"}]
assert validateAndCoerceParameters(actionDef, {"documentList": docs})["documentList"] is docs
def test_doesNotMutateInput(self):
"""validateAndCoerceParameters must return a new dict."""
actionDef = _makeActionDef(
featureInstanceId={"type": "FeatureInstanceRef", "required": True},
)
original = {"featureInstanceId": {"id": "uuid", "featureCode": "trustee"}}
result = validateAndCoerceParameters(actionDef, original)
assert isinstance(original["featureInstanceId"], dict)
assert result["featureInstanceId"] == "uuid"