From 18b4800e49ab8463b563e9129edcfc33da1143e8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 28 May 2026 19:03:00 +0200 Subject: [PATCH] test(automations/dispatch): lock _validate_inputs + DispatchError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover the input-validation contract dispatch_run relies on: - no declared schema → inputs pass through unchanged (regression site that previously stripped runtime keys like fired_at / last_fired_at and broke Jinja templates). - declared schema, valid inputs → passthrough validated. - declared schema, invalid inputs → DispatchError (uniform exception type, not raw jsonschema.ValidationError). Plus the DispatchError exception identity (Exception subclass, message preserved, isinstance-friendly for the dispatch layer's consumers). 4 tests, pure unit. --- .../unit/automations/dispatch/__init__.py | 0 .../unit/automations/dispatch/test_errors.py | 28 +++++++ .../dispatch/test_validate_inputs.py | 77 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 surfsense_backend/tests/unit/automations/dispatch/__init__.py create mode 100644 surfsense_backend/tests/unit/automations/dispatch/test_errors.py create mode 100644 surfsense_backend/tests/unit/automations/dispatch/test_validate_inputs.py diff --git a/surfsense_backend/tests/unit/automations/dispatch/__init__.py b/surfsense_backend/tests/unit/automations/dispatch/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/automations/dispatch/test_errors.py b/surfsense_backend/tests/unit/automations/dispatch/test_errors.py new file mode 100644 index 000000000..89c1bede9 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/dispatch/test_errors.py @@ -0,0 +1,28 @@ +"""Lock the ``DispatchError`` exception contract. + +``DispatchError`` is the uniform exception type the dispatch layer raises +for any "cannot turn this fire request into a run" condition. Other +modules (templates of error envelopes, run records) compare on +``isinstance(exc, DispatchError)``, so the inheritance is the contract. +""" + +from __future__ import annotations + +import pytest + +from app.automations.dispatch.errors import DispatchError + +pytestmark = pytest.mark.unit + + +def test_dispatch_error_is_exception_subclass_and_carries_message() -> None: + """Lifting a string into ``DispatchError`` preserves the message and + behaves as a regular ``Exception`` for ``isinstance`` / ``raise`` / + ``except`` consumers.""" + error = DispatchError("missing trigger") + + assert isinstance(error, Exception) + assert str(error) == "missing trigger" + + with pytest.raises(DispatchError): + raise error diff --git a/surfsense_backend/tests/unit/automations/dispatch/test_validate_inputs.py b/surfsense_backend/tests/unit/automations/dispatch/test_validate_inputs.py new file mode 100644 index 000000000..ec99e51c2 --- /dev/null +++ b/surfsense_backend/tests/unit/automations/dispatch/test_validate_inputs.py @@ -0,0 +1,77 @@ +"""Lock the input-validation contract used by ``dispatch_run``. + +``_validate_inputs`` is module-internal by convention (underscore), but it +encodes a real behavior contract the rest of the system depends on, and the +public alternative (``dispatch_run``) requires a real DB session. Tests +target the pure function directly; the contract — not the symbol — is what's +locked. +""" + +from __future__ import annotations + +import pytest + +from app.automations.dispatch.errors import DispatchError +from app.automations.dispatch.run import _validate_inputs +from app.automations.schemas.definition.envelope import AutomationDefinition +from app.automations.schemas.definition.inputs import Inputs +from app.automations.schemas.definition.plan_step import PlanStep + +pytestmark = pytest.mark.unit + + +def _minimal_definition(*, inputs: Inputs | None = None) -> AutomationDefinition: + """One-step definition with an optional declared input schema.""" + return AutomationDefinition( + name="test", + inputs=inputs, + plan=[PlanStep(step_id="s1", action="agent_task")], + ) + + +def test_validate_inputs_passes_through_when_no_schema_is_declared() -> None: + """When the definition declares no input schema, runtime inputs reach + the template context **unchanged**. Regression site: previously this + branch returned ``{}``, which stripped runtime keys like ``fired_at`` + and ``last_fired_at`` and made Jinja blow up on ``{{ inputs.* }}``. + """ + definition = _minimal_definition(inputs=None) + runtime_inputs = { + "fired_at": "2026-01-01T00:00:00+00:00", + "last_fired_at": None, + "static_key": "value", + } + + assert _validate_inputs(definition, runtime_inputs) == runtime_inputs + + +def test_validate_inputs_returns_inputs_when_they_match_declared_schema() -> None: + """With a declared JSON schema, inputs that satisfy it pass through + unchanged (validation succeeds; the function does not coerce or + strip extra fields not mentioned in the schema).""" + schema = { + "type": "object", + "properties": {"topic": {"type": "string"}}, + "required": ["topic"], + } + definition = _minimal_definition(inputs=Inputs(schema=schema)) + + inputs = {"topic": "weekly report"} + + assert _validate_inputs(definition, inputs) == inputs + + +def test_validate_inputs_raises_dispatch_error_when_inputs_violate_schema() -> None: + """Inputs that don't match the declared schema must surface as + ``DispatchError`` (not the raw ``jsonschema.ValidationError``), so the + schedule tick and any other caller can handle one dispatch-domain + exception type uniformly.""" + schema = { + "type": "object", + "properties": {"topic": {"type": "string"}}, + "required": ["topic"], + } + definition = _minimal_definition(inputs=Inputs(schema=schema)) + + with pytest.raises(DispatchError): + _validate_inputs(definition, {"topic": 42}) # type violates string