mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
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:
parent
acbeb60a43
commit
822940b09e
11 changed files with 396 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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")],
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"})
|
||||
|
|
@ -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 == []
|
||||
|
|
@ -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"}
|
||||
)
|
||||
|
|
@ -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": {}})
|
||||
Loading…
Add table
Add a link
Reference in a new issue