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,82 @@
"""Lock the request-side automation API schemas — the public validation gate."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.schemas.api.automation import AutomationCreate, AutomationUpdate
pytestmark = pytest.mark.unit
_VALID_DEFINITION = {
"name": "Test",
"plan": [{"step_id": "s1", "action": "agent_task"}],
}
def test_automation_create_accepts_valid_minimal_payload() -> None:
"""Happy path: just search_space_id, name, and a valid definition.
Triggers default to ``[]`` so users can attach them later."""
payload = AutomationCreate.model_validate(
{
"search_space_id": 1,
"name": "Daily digest",
"definition": _VALID_DEFINITION,
}
)
assert payload.name == "Daily digest"
assert payload.description is None
assert payload.triggers == []
def test_automation_create_cascades_validation_into_nested_definition() -> None:
"""A bad ``definition`` (e.g. empty plan) fails at the API boundary,
not at the DB layer. Locks the cascade so corrupt definitions can't
sneak through a misshapen wire payload."""
with pytest.raises(ValidationError):
AutomationCreate.model_validate(
{
"search_space_id": 1,
"name": "Bad",
"definition": {"name": "X", "plan": []}, # empty plan
}
)
def test_automation_create_rejects_unknown_top_level_field() -> None:
"""``extra='forbid'`` catches typos in API payloads at the boundary."""
with pytest.raises(ValidationError):
AutomationCreate.model_validate(
{
"search_space_id": 1,
"name": "X",
"definition": _VALID_DEFINITION,
"owner": "tg", # not allowed
}
)
def test_automation_create_rejects_empty_name() -> None:
"""Name is required and constrained to 1..200 chars."""
with pytest.raises(ValidationError):
AutomationCreate.model_validate(
{
"search_space_id": 1,
"name": "",
"definition": _VALID_DEFINITION,
}
)
def test_automation_update_accepts_partial_payload_with_no_fields() -> None:
"""All fields on ``AutomationUpdate`` are optional. An empty body is
a valid no-op update (the service layer decides what to do with it)."""
update = AutomationUpdate.model_validate({})
assert update.name is None
assert update.description is None
assert update.status is None
assert update.definition is None

View file

@ -0,0 +1,47 @@
"""Lock the request-side trigger API schemas."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from app.automations.persistence.enums.trigger_type import TriggerType
from app.automations.schemas.api.trigger import TriggerCreate, TriggerUpdate
pytestmark = pytest.mark.unit
def test_trigger_create_uses_safe_defaults_for_optional_fields() -> None:
"""Defaults: empty ``params`` and ``static_inputs``, ``enabled=True``.
These let callers create a trigger with just ``type`` + the params
the trigger requires."""
trigger = TriggerCreate(type=TriggerType.SCHEDULE) # type: ignore[arg-type]
assert trigger.type is TriggerType.SCHEDULE
assert trigger.params == {}
assert trigger.static_inputs == {}
assert trigger.enabled is True
def test_trigger_create_rejects_unknown_trigger_type_string() -> None:
"""``type`` is a ``TriggerType`` enum, so any string outside the
enum's known values fails validation at the boundary."""
with pytest.raises(ValidationError):
TriggerCreate.model_validate({"type": "webhook"}) # not in TriggerType
def test_trigger_create_rejects_unknown_field() -> None:
"""``extra='forbid'`` catches typos in trigger payloads."""
with pytest.raises(ValidationError):
TriggerCreate.model_validate(
{"type": "schedule", "param": {}} # typo: param vs params
)
def test_trigger_update_accepts_partial_payload_with_no_fields() -> None:
"""``TriggerUpdate`` is fully optional — empty body is valid (no-op)."""
update = TriggerUpdate()
assert update.enabled is None
assert update.params is None
assert update.static_inputs is None

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": {}})