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

289 lines
9.5 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Tests for the action-signature validator (Phase 2 of the Typed Action
Architecture, see wiki/c-work/1-plan/2026-04-typed-action-architecture.md).
Two parts:
A) Unit tests for the validator itself (positive + negative cases)
B) Healthy-state test: every Method discovered by methodDiscovery passes
validation. This is the regression net that catches drift between an
action's declared types and the type catalog.
"""
from __future__ import annotations
import pytest
from modules.datamodels.datamodelWorkflowActions import (
WorkflowActionDefinition,
WorkflowActionParameter,
)
from modules.shared.frontendTypes import FrontendType
from modules.workflows.methods._actionSignatureValidator import (
_formatValidationReport,
_validateActionDefinition,
_validateActionParameter,
_validateActionsDict,
_validateMethods,
_validateTypeRef,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _makeParam(typeStr: str, **kwargs) -> WorkflowActionParameter:
defaults = {
"name": "p",
"type": typeStr,
"frontendType": FrontendType.TEXT,
"required": False,
"description": "",
}
defaults.update(kwargs)
return WorkflowActionParameter(**defaults)
def _makeAction(
actionId: str = "test.x",
parameters: dict | None = None,
outputType: str = "ActionResult",
) -> WorkflowActionDefinition:
return WorkflowActionDefinition(
actionId=actionId,
description="t",
parameters=parameters or {},
outputType=outputType,
execute=lambda *a, **k: None,
)
# ---------------------------------------------------------------------------
# A) Unit tests
# ---------------------------------------------------------------------------
class TestValidateTypeRef:
"""Single-type validation."""
@pytest.mark.parametrize("t", [
"str", "int", "bool", "float", "Any",
"ConnectionRef", "FeatureInstanceRef", "DocumentList",
"TrusteeProcessResult", "RedmineTicket",
"List[str]", "List[int]", "List[Any]",
"Dict[str,Any]", "Dict[str,Document]",
"List[FeatureInstanceRef]",
])
def test_validTypes(self, t):
assert _validateTypeRef(t) == []
@pytest.mark.parametrize("t", [
"list", # too generic
"dict", # too generic
"Foobar", # unknown schema
"List[Foo]", # unknown inner
"Dict[str,Foo]", # unknown inner value
"", # empty
])
def test_invalidTypes(self, t):
errors = _validateTypeRef(t)
assert errors, f"expected validation errors for {t!r}"
class TestValidateActionParameter:
def test_validParam(self):
p = _makeParam("ConnectionRef")
assert _validateActionParameter("ai.x", "p", p) == []
def test_invalidParam(self):
p = _makeParam("Foobar")
errors = _validateActionParameter("ai.x", "myParam", p)
assert errors and errors[0].startswith("ai.x.myParam:")
class TestValidateActionDefinition:
def test_valid(self):
action = _makeAction(
parameters={"a": _makeParam("ConnectionRef", name="a")},
outputType="DocumentList",
)
assert _validateActionDefinition(action) == []
def test_invalidOutputType(self):
action = _makeAction(outputType="DoesNotExist")
errors = _validateActionDefinition(action)
assert any("<outputType>" in e for e in errors)
def test_genericOutputAllowed(self):
# ActionResult and Transit are allowed as fire-and-forget outputs
for t in ("ActionResult", "Transit"):
assert _validateActionDefinition(_makeAction(outputType=t)) == []
class TestValidateActionsDict:
def test_emptyDictOk(self):
assert _validateActionsDict("m", {}) == []
def test_nonActionDefinitionRejected(self):
errors = _validateActionsDict("m", {"x": "not an action"})
assert any("not a WorkflowActionDefinition" in e for e in errors)
def test_collectsErrorsAcrossActions(self):
actions = {
"good": _makeAction(
parameters={"a": _makeParam("str", name="a")},
outputType="DocumentList",
),
"bad": _makeAction(
actionId="m.bad",
parameters={"x": _makeParam("Foobar", name="x")},
outputType="AlsoUnknown",
),
}
errors = _validateActionsDict("m", actions)
# bad action contributes 2 errors, good contributes 0
assert len(errors) == 2
class TestValidateMethods:
def test_emptyOk(self):
assert _validateMethods([]) == []
def test_methodLikeObject(self):
class FakeMethod:
name = "fake"
def __init__(self):
self._actions = {
"a": _makeAction(
parameters={"p": _makeParam("ConnectionRef", name="p")},
outputType="DocumentList",
),
}
assert _validateMethods([FakeMethod()]) == []
def test_methodWithDrift(self):
class FakeMethod:
name = "fake"
def __init__(self):
self._actions = {
"broken": _makeAction(
actionId="fake.broken",
parameters={"p": _makeParam("Unknown", name="p")},
outputType="ActionResult",
),
}
errors = _validateMethods([FakeMethod()])
assert errors and "fake.broken.p" in errors[0]
class TestFormatValidationReport:
def test_healthyMessage(self):
assert "healthy" in _formatValidationReport([]).lower()
def test_errorReport(self):
msg = _formatValidationReport(["a.x: bad", "b.y: also bad"])
assert "Found 2 action-signature drift" in msg
assert "a.x: bad" in msg
assert "b.y: also bad" in msg
# ---------------------------------------------------------------------------
# B) Healthy-state test for the real Method registry
# ---------------------------------------------------------------------------
class _NullRbac:
"""Minimal RBAC stub so MethodBase.__init__ does not crash."""
def getUserPermissions(self, **kwargs): # noqa: D401
class _P:
view = True
read = True
create = True
update = True
delete = True
return _P()
class _StubServices:
"""Minimal services container required by MethodBase.__init__."""
def __init__(self):
self.rbac = _NullRbac()
self.user = type("U", (), {"id": "test-user", "roleLabels": []})()
self.mandateId = None
self.featureInstanceId = None
def _ensureOptionalDeps():
"""Patch sys.modules with stubs for optional deps that some Methods
import at module-load time but that the test env might not have.
This is purely so the validator can inspect the action signatures —
no real network calls happen in these tests.
"""
import sys
import types
class _AnyAttrModule(types.ModuleType):
"""Module stub that lazily creates dummy classes for any attribute,
so type annotations like `aiohttp.ClientSession` resolve."""
def __getattr__(self, name): # noqa: D401
return type(name, (), {})
for name in ("aiohttp",):
if name not in sys.modules:
sys.modules[name] = _AnyAttrModule(name)
def _instantiateMethod(methodCls):
"""Try to instantiate a Method with a stub services object.
Some Methods do extra work in __init__ (e.g. helper imports). We
accept failures and return None; missing Methods are skipped.
"""
_ensureOptionalDeps()
try:
return methodCls(_StubServices())
except Exception as exc: # pragma: no cover - environment dependent
pytest.skip(f"could not instantiate {methodCls.__name__}: {exc}")
return None
@pytest.mark.parametrize("modulePath,className", [
("modules.workflows.methods.methodTrustee.methodTrustee", "MethodTrustee"),
("modules.workflows.methods.methodRedmine.methodRedmine", "MethodRedmine"),
("modules.workflows.methods.methodSharepoint.methodSharepoint", "MethodSharepoint"),
("modules.workflows.methods.methodOutlook.methodOutlook", "MethodOutlook"),
("modules.workflows.methods.methodAi.methodAi", "MethodAi"),
("modules.workflows.methods.methodClickup.methodClickup", "MethodClickup"),
("modules.workflows.methods.methodFile.methodFile", "MethodFile"),
("modules.workflows.methods.methodContext.methodContext", "MethodContext"),
("modules.workflows.methods.methodJira.methodJira", "MethodJira"),
("modules.workflows.methods.methodChatbot.methodChatbot", "MethodChatbot"),
])
def test_methodSignaturesAreHealthy(modulePath, className):
"""Each shipping Method's _actions must validate against the catalog."""
import importlib
try:
module = importlib.import_module(modulePath)
except ImportError as exc:
pytest.skip(f"module not importable: {exc}")
return
cls = getattr(module, className, None)
if cls is None:
pytest.skip(f"{className} not found in {modulePath}")
return
instance = _instantiateMethod(cls)
if instance is None:
return
errors = _validateMethods([instance])
assert errors == [], _formatValidationReport(errors)