test(automations/templating): lock render, filters, environment, context

render.py (4): variable substitution, StrictUndefined raises on missing
keys, evaluate_predicate coerces to bool, render_value walks dicts/lists
and renders string leaves.

filters.py (4): slugify produces URL-safe output, date formats datetime
with strftime, date(None) → "" so templates can write
{{ inputs.last_fired_at | date }} on first run, date(str) passes through.

environment.py (4): the sandbox boundary — disallowed Jinja built-ins
(e.g. pprint) raise, and the finalize hook coerces non-string outputs
to predictable wire shapes (datetime → ISO, None → "", dict → JSON).

context.py (1): build_run_context exposes {run, inputs, steps} with the
exact shape every plan template body relies on.

13 tests total, all pure unit.
This commit is contained in:
CREDO23 2026-05-28 19:03:22 +02:00
parent 49af95b652
commit db4eef651f
5 changed files with 205 additions and 0 deletions

View file

@ -0,0 +1,53 @@
"""Lock the ``{run, inputs, steps}`` namespace exposed to every template."""
from __future__ import annotations
from datetime import UTC, datetime
from uuid import UUID
import pytest
from app.automations.templating.context import build_run_context
pytestmark = pytest.mark.unit
def test_build_run_context_exposes_run_inputs_and_steps_namespaces() -> None:
"""The namespace handed to templates groups run metadata under ``run``,
runtime + static inputs under ``inputs``, and step outputs (keyed by
``output_as`` / ``step_id``) under ``steps``. Locks the contract that
every plan template body relies on."""
creator = UUID("00000000-0000-0000-0000-000000000001")
started = datetime(2026, 5, 28, 14, 30, tzinfo=UTC)
ctx = build_run_context(
run_id=42,
automation_id=7,
automation_name="Weekly digest",
automation_version=3,
search_space_id=1,
creator_id=creator,
trigger_id=11,
trigger_type="schedule",
started_at=started,
attempt=2,
inputs={"topic": "weekly"},
step_outputs={"summarize": {"text": "ok"}},
)
assert ctx == {
"run": {
"id": 42,
"automation_id": 7,
"automation_name": "Weekly digest",
"automation_version": 3,
"search_space_id": 1,
"creator_id": creator,
"trigger_id": 11,
"trigger_type": "schedule",
"started_at": started,
"attempt": 2,
},
"inputs": {"topic": "weekly"},
"steps": {"summarize": {"text": "ok"}},
}

View file

@ -0,0 +1,51 @@
"""Lock the sandbox boundary: disallowed filters/tests reject, finalize coerces non-strings.
These behaviors live in ``environment.py`` but are observed through the
public ``render_template`` surface the same surface every step uses.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from jinja2.exceptions import TemplateError
from app.automations.templating.render import render_template
pytestmark = pytest.mark.unit
def test_environment_rejects_filters_not_in_the_allowlist() -> None:
"""A template that pipes through a Jinja built-in **not** in the
allowlist (e.g. ``pprint``) must fail rather than rendering. Locks
the sandbox surface against accidental re-introduction of removed
filters."""
with pytest.raises(TemplateError):
render_template("{{ value | pprint }}", {"value": {"k": 1}})
def test_environment_finalizes_datetime_output_to_iso_string() -> None:
"""A datetime that lands directly at an output site is stringified
via ``isoformat()`` rather than producing ``str(datetime)`` (which
has a space separator). Locks the wire shape templates produce
when emitting ``inputs.fired_at`` and other datetime values."""
dt = datetime(2026, 5, 28, 14, 30, tzinfo=UTC)
assert render_template("{{ moment }}", {"moment": dt}) == "2026-05-28T14:30:00+00:00"
def test_environment_finalizes_none_output_to_empty_string() -> None:
"""A ``None`` at an output site becomes the empty string. Lets
templates write ``{{ inputs.last_fired_at }}`` unconditionally on
the first run without exploding on the null."""
assert render_template("{{ missing }}", {"missing": None}) == ""
def test_environment_finalizes_dict_output_to_json() -> None:
"""A dict at an output site is JSON-serialized. Same for lists.
Locks the wire shape so users embedding structured values into
prompts get deterministic, parseable output."""
rendered = render_template("{{ payload }}", {"payload": {"a": 1, "b": [2, 3]}})
assert rendered == '{"a": 1, "b": [2, 3]}'

View file

@ -0,0 +1,42 @@
"""Lock the custom Jinja filters: ``date`` and ``slugify``."""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from app.automations.templating.filters import filter_date, filter_slugify
pytestmark = pytest.mark.unit
def test_filter_slugify_produces_url_safe_slug_from_typical_title() -> None:
"""``filter_slugify`` lowercases, replaces non-alphanumerics with
hyphens, collapses repeats, and trims edge hyphens the standard
URL-slug contract users expect when piping titles into paths."""
assert filter_slugify("Hello, World! 2026") == "hello-world-2026"
def test_filter_date_formats_datetime_with_strftime_format() -> None:
"""``filter_date`` calls ``strftime`` on datetime-like values with the
provided format. Default format yields ISO date (YYYY-MM-DD)."""
dt = datetime(2026, 5, 28, 14, 30, tzinfo=UTC)
assert filter_date(dt) == "2026-05-28"
assert filter_date(dt, "%Y/%m/%d %H:%M") == "2026/05/28 14:30"
def test_filter_date_returns_empty_string_for_none() -> None:
"""``None`` (e.g., a never-fired ``last_fired_at``) renders as the
empty string rather than the literal ``"None"`` or raising. This is
what lets templates write ``{{ inputs.last_fired_at | date }}``
unconditionally on the first run."""
assert filter_date(None) == ""
def test_filter_date_passes_strings_through_unchanged() -> None:
"""Already-formatted ISO strings (the JSON-serialized shape of
runtime inputs like ``fired_at``) pass through unchanged so callers
don't have to special-case the type."""
assert filter_date("2026-05-28T14:30:00+00:00") == "2026-05-28T14:30:00+00:00"

View file

@ -0,0 +1,59 @@
"""Lock the public template-rendering surface: render, predicate, recursive."""
from __future__ import annotations
import pytest
from jinja2 import UndefinedError
from app.automations.templating.render import (
evaluate_predicate,
render_template,
render_value,
)
pytestmark = pytest.mark.unit
def test_render_template_substitutes_context_variables() -> None:
"""A template referencing a context variable produces the substituted
string. Most basic contract of the template engine."""
result = render_template("Hello {{ name }}!", {"name": "World"})
assert result == "Hello World!"
def test_render_template_raises_on_undefined_variable() -> None:
"""Referencing a variable that isn't in the context raises rather than
rendering the empty string. Locks the StrictUndefined safety net so
template typos surface as run failures instead of silent corruption."""
with pytest.raises(UndefinedError):
render_template("Hello {{ missing }}!", {})
def test_evaluate_predicate_returns_truthy_outcome_of_expression() -> None:
"""``evaluate_predicate`` compiles a Jinja **expression** (not template
body) and coerces the value to ``bool``. Drives ``step.when`` gating."""
assert evaluate_predicate("inputs.count > 0", {"inputs": {"count": 3}}) is True
assert evaluate_predicate("inputs.count > 0", {"inputs": {"count": 0}}) is False
def test_render_value_renders_strings_recursively_through_dicts_and_lists() -> None:
"""``render_value`` walks dicts and lists, renders string leaves through
the template engine, and leaves non-strings untouched. This is the
primitive ``execute_step`` uses to render step params at run time."""
context = {"inputs": {"name": "World"}, "topic": "weekly"}
rendered = render_value(
{
"greeting": "Hello {{ inputs.name }}",
"tags": ["{{ topic }}", "static"],
"config": {"retries": 3, "label": "{{ topic }}-{{ inputs.name }}"},
},
context,
)
assert rendered == {
"greeting": "Hello World",
"tags": ["weekly", "static"],
"config": {"retries": 3, "label": "weekly-World"},
}