mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-29 19:35:20 +02:00
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:
parent
49af95b652
commit
db4eef651f
5 changed files with 205 additions and 0 deletions
|
|
@ -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"}},
|
||||
}
|
||||
|
|
@ -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]}'
|
||||
|
|
@ -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"
|
||||
|
|
@ -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"},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue