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