206 lines
8.9 KiB
Python
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"
|