test(automations/schemas): lock definition + api validation gates

definition/ (29 tests): the envelope (defaults, extra=forbid, empty
plan/name rejection), Inputs schema-alias roundtrip (Python schema_ ↔
wire schema), PlanStep numeric bounds + addressing-field constraints,
Execution production defaults stability (10-min timeout, 2 retries,
exponential backoff, drop_if_running) + closed-set Literal gates,
Metadata's exceptional extra="allow" contract, and TriggerSpec type
requirement.

api/ (9 tests): AutomationCreate/Update cascade-validate into the
nested definition, reject unknown payload fields, enforce name length;
TriggerCreate exposes safe defaults (enabled=True, params={},
static_inputs={}) and rejects unknown TriggerType strings at the
boundary.

All pure unit, no DB.
This commit is contained in:
CREDO23 2026-05-28 19:03:42 +02:00
parent acbeb60a43
commit 822940b09e
11 changed files with 396 additions and 0 deletions

View file

@ -0,0 +1,57 @@
"""Lock the ``AutomationDefinition`` envelope contract."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.schemas.definition.envelope import AutomationDefinition
from app.automations.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit
def test_automation_definition_accepts_minimal_valid_input_with_sensible_defaults() -> None:
"""A definition with just ``name`` + a one-step ``plan`` is valid and
fills in the rest with safe defaults so users don't have to write
out every section to get started."""
definition = AutomationDefinition(
name="Daily digest",
plan=[PlanStep(step_id="s1", action="agent_task")],
)
assert definition.name == "Daily digest"
assert definition.schema_version == "1.0"
assert definition.goal is None
assert definition.inputs is None
assert definition.triggers == []
def test_automation_definition_rejects_unknown_top_level_field() -> None:
"""``extra='forbid'`` catches typos at validation time (e.g. ``pln``
instead of ``plan``) before the bad definition reaches storage."""
with pytest.raises(ValidationError):
AutomationDefinition.model_validate(
{
"name": "X",
"plan": [{"step_id": "s1", "action": "agent_task"}],
"extra_field": "unexpected",
}
)
def test_automation_definition_rejects_empty_plan() -> None:
"""An automation with no plan steps has nothing to execute and must
be rejected at validation time."""
with pytest.raises(ValidationError):
AutomationDefinition(name="X", plan=[])
def test_automation_definition_rejects_empty_name() -> None:
"""Name is required and must be non-empty so list views and audit
logs have something meaningful to display."""
with pytest.raises(ValidationError):
AutomationDefinition(
name="",
plan=[PlanStep(step_id="s1", action="agent_task")],
)

View file

@ -0,0 +1,49 @@
"""Lock the ``Execution`` defaults + literal-constraint contract.
These defaults control production behavior of every automation that
doesn't override them; the defaults *are* the contract.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.schemas.definition.execution import Execution
pytestmark = pytest.mark.unit
def test_execution_uses_production_defaults_when_no_overrides_provided() -> None:
"""The defaults shipped to prod: 10-minute wall clock, 2 retries
per step, exponential backoff, drop overlapping runs. Changing any
of these is a behavioral release-note change."""
execution = Execution()
assert execution.timeout_seconds == 600
assert execution.max_retries == 2
assert execution.retry_backoff == "exponential"
assert execution.concurrency == "drop_if_running"
assert execution.on_failure == []
def test_execution_rejects_unknown_retry_backoff_strategy() -> None:
"""``retry_backoff`` is constrained to a closed set — typos like
``"expontential"`` must fail validation, not silently coerce."""
with pytest.raises(ValidationError):
Execution(retry_backoff="expontential") # type: ignore[arg-type]
def test_execution_rejects_unknown_concurrency_strategy() -> None:
"""Same closed-set constraint on ``concurrency``."""
with pytest.raises(ValidationError):
Execution(concurrency="parallel") # type: ignore[arg-type]
def test_execution_rejects_invalid_numeric_bounds() -> None:
"""``timeout_seconds > 0`` and ``max_retries >= 0``. Zero or negative
values would produce nonsensical run behavior."""
with pytest.raises(ValidationError):
Execution(timeout_seconds=0)
with pytest.raises(ValidationError):
Execution(max_retries=-1)

View file

@ -0,0 +1,39 @@
"""Lock the ``Inputs`` JSON ``schema``-alias roundtrip.
The field is ``schema_`` in Python (``schema`` shadows a Pydantic builtin)
but is wire-named ``schema``. Locking the roundtrip means JSON definitions
authored anywhere (UI raw editor, NL drafter, CLI export) speak the same
wire shape.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.schemas.definition.inputs import Inputs
pytestmark = pytest.mark.unit
def test_inputs_parses_wire_field_named_schema_into_schema_attribute() -> None:
"""JSON payloads use ``schema`` (the convention). The model stores it
on the Python attribute ``schema_`` without shadowing the builtin."""
parsed = Inputs.model_validate({"schema": {"type": "object"}})
assert parsed.schema_ == {"type": "object"}
def test_inputs_serializes_schema_attribute_back_to_wire_field_named_schema() -> None:
"""Round-trip: serializing emits ``schema`` (alias), not ``schema_``.
Locks the consumer-visible JSON shape regardless of the Python name."""
inputs = Inputs(schema={"type": "object"}) # type: ignore[call-arg]
assert inputs.model_dump() == {"schema": {"type": "object"}}
def test_inputs_rejects_unknown_field() -> None:
"""``extra='forbid'`` catches typos like ``shema`` so bad definitions
don't silently lose their input declaration."""
with pytest.raises(ValidationError):
Inputs.model_validate({"schema": {}, "extra": "x"})

View file

@ -0,0 +1,37 @@
"""Lock the ``Metadata`` ``extra='allow'`` contract — the only schema
that does. Free-form annotations on definitions (e.g. ``owner``,
``project``, ``created_by_ai``) need to round-trip through the envelope
without being rejected.
"""
from __future__ import annotations
import pytest
from app.automations.schemas.definition.metadata import Metadata
pytestmark = pytest.mark.unit
def test_metadata_preserves_unknown_keys() -> None:
"""Unlike every other definition sub-schema, ``Metadata`` allows
extra keys and round-trips them that's its purpose."""
metadata = Metadata.model_validate(
{
"tags": ["weekly", "report"],
"owner": "tg",
"created_by_ai": True,
}
)
dumped = metadata.model_dump()
assert dumped["tags"] == ["weekly", "report"]
assert dumped["owner"] == "tg"
assert dumped["created_by_ai"] is True
def test_metadata_defaults_tags_to_empty_list() -> None:
"""No tags is the common case; the default is the empty list so
callers can append without a None check."""
assert Metadata().tags == []

View file

@ -0,0 +1,52 @@
"""Lock the ``PlanStep`` validation contract."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.schemas.definition.plan_step import PlanStep
pytestmark = pytest.mark.unit
def test_plan_step_accepts_minimal_input_with_safe_defaults() -> None:
"""A step with just ``step_id`` + ``action`` is valid. Defaults
(no when, empty params, no output_as override, no retry/timeout
override) let the run inherit automation-wide defaults."""
step = PlanStep(step_id="s1", action="agent_task")
assert step.step_id == "s1"
assert step.action == "agent_task"
assert step.when is None
assert step.params == {}
assert step.output_as is None
assert step.max_retries is None
assert step.timeout_seconds is None
def test_plan_step_rejects_empty_step_id_and_action() -> None:
"""``step_id`` and ``action`` are addressing primitives — empty
strings would silently break runtime lookups."""
with pytest.raises(ValidationError):
PlanStep(step_id="", action="agent_task")
with pytest.raises(ValidationError):
PlanStep(step_id="s1", action="")
def test_plan_step_rejects_negative_max_retries_and_non_positive_timeout() -> None:
"""Numeric constraints: ``max_retries >= 0`` and ``timeout_seconds > 0``.
Negative budgets or zero timeouts produce nonsensical run behavior."""
with pytest.raises(ValidationError):
PlanStep(step_id="s1", action="agent_task", max_retries=-1)
with pytest.raises(ValidationError):
PlanStep(step_id="s1", action="agent_task", timeout_seconds=0)
def test_plan_step_rejects_unknown_field() -> None:
"""``extra='forbid'`` catches typos like ``actoin`` (instead of
``action``) before the bad step reaches storage."""
with pytest.raises(ValidationError):
PlanStep.model_validate(
{"step_id": "s1", "action": "agent_task", "actoin": "agent_task"}
)

View file

@ -0,0 +1,33 @@
"""Lock the ``TriggerSpec`` validation contract — the entry shape used
inside an automation's ``triggers[]`` array.
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.schemas.definition.trigger_spec import TriggerSpec
pytestmark = pytest.mark.unit
def test_trigger_spec_accepts_type_with_default_empty_params() -> None:
"""``type`` is required; ``params`` defaults to ``{}`` so triggers
that take no params don't need an explicit body."""
spec = TriggerSpec(type="schedule")
assert spec.type == "schedule"
assert spec.params == {}
def test_trigger_spec_rejects_empty_type() -> None:
"""``type`` is the registry lookup key — empty would silently miss."""
with pytest.raises(ValidationError):
TriggerSpec(type="")
def test_trigger_spec_rejects_unknown_field() -> None:
"""``extra='forbid'`` catches typos at definition-validation time."""
with pytest.raises(ValidationError):
TriggerSpec.model_validate({"type": "schedule", "paramz": {}})