289 lines
9.5 KiB
Python
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)
|