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