jinjaturtle/tests/test_json_handler.py
Miguel Jacq be1f9940a5
Some checks failed
CI / test (push) Successful in 1m20s
CI / test (debian, docker.io/library/debian:13, python3) (push) Successful in 1m20s
Lint / test (push) Failing after 40s
More hardening measures
2026-06-24 16:17:41 +10:00

181 lines
6.1 KiB
Python

from __future__ import annotations
from pathlib import Path
import json
import pytest
import yaml
from jinja2 import Environment
from jinjaturtle.core import (
parse_config,
flatten_config,
generate_ansible_yaml,
analyze_loops,
generate_jinja2_template,
)
from jinjaturtle.handlers.json import JsonHandler
from jinjaturtle.loop_analyzer import LoopCandidate
SAMPLES_DIR = Path(__file__).parent / "samples"
def test_json_roundtrip():
json_path = SAMPLES_DIR / "foo.json"
assert json_path.is_file(), f"Missing sample JSON file: {json_path}"
fmt, parsed = parse_config(json_path)
assert fmt == "json"
# With loop detection
loop_candidates = analyze_loops(fmt, parsed)
flat_items = flatten_config(fmt, parsed, loop_candidates)
ansible_yaml = generate_ansible_yaml("foobar", flat_items, loop_candidates)
defaults = yaml.safe_load(ansible_yaml)
# Defaults: nested keys
assert defaults["foobar_foo"] == "bar"
assert defaults["foobar_nested_a"] == 1
# Booleans are now preserved as booleans (not stringified)
assert defaults["foobar_nested_b"] is True
# JSON stays scalar/index-based so source formatting can be preserved.
assert loop_candidates == []
assert defaults["foobar_list_0"] == 10
assert defaults["foobar_list_1"] == 20
template = generate_jinja2_template("json", parsed, "foobar", None, loop_candidates)
# Template should use Ansible | to_json for type preservation without
# HTML-safe escaping.
assert "{{ foobar_foo | to_json(ensure_ascii=False) }}" in template
assert "{{ foobar_nested_a | to_json(ensure_ascii=False) }}" in template
assert "{{ foobar_nested_b | to_json(ensure_ascii=False) }}" in template
assert "{{ foobar_list_0 | to_json(ensure_ascii=False) }}" in template
assert "{{ foobar_list_1 | to_json(ensure_ascii=False) }}" in template
assert "{% for" not in template
def test_json_template_preserves_original_indentation_and_final_newline_state(
tmp_path: Path,
):
json_text = (
"{\n"
' "default-runtime": "runsc",\n'
' "runtimes": {\n'
' "runsc": {\n'
' "path": "/usr/bin/runsc"\n'
" }\n"
" }\n"
"}"
)
path = tmp_path / "daemon.json"
path.write_text(json_text, encoding="utf-8")
fmt, parsed = parse_config(path)
template = generate_jinja2_template(fmt, parsed, "docker", json_text, [])
assert ' "default-runtime"' in template
assert ' "runsc"' in template
assert ' "path"' in template
assert not template.endswith("\n")
def test_json_template_uses_non_html_safe_json_filter_for_angle_brackets(
tmp_path: Path,
):
json_text = '{"version_requirement": ">= 8.0.0 < 9.0.0"}\n'
path = tmp_path / "metadata.json"
path.write_text(json_text, encoding="utf-8")
fmt, parsed = parse_config(path)
template = generate_jinja2_template(fmt, parsed, "puppet", json_text, [])
assert "to_json(ensure_ascii=False)" in template
assert "tojson" not in template
def test_json_inline_object_array_roundtrips_without_expanding(tmp_path: Path):
json_text = (
"{\n"
' "description": "Gets the last boot time of a Linux or Windows system",\n'
' "implementations": [\n'
' {"name": "last_boot_time_nix.sh", "requirements": ["shell"]},\n'
' {"name": "last_boot_time_win.ps1", "requirements": ["powershell"]}\n'
" ]\n"
"}\n"
)
path = tmp_path / "metadata.json"
path.write_text(json_text, encoding="utf-8")
fmt, parsed = parse_config(path)
loop_candidates = analyze_loops(fmt, parsed)
flat_items = flatten_config(fmt, parsed, loop_candidates)
defaults = yaml.safe_load(
generate_ansible_yaml("fact", flat_items, loop_candidates)
)
template = generate_jinja2_template(fmt, parsed, "fact", json_text, loop_candidates)
env = Environment(keep_trailing_newline=True)
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
rendered = env.from_string(template).render(**defaults)
assert loop_candidates == []
assert '{"name": {{ fact_implementations_0_name' in template
assert rendered == json_text
def test_json_direct_loop_generation_renders_valid_json_without_joined_objects():
items = [
{"name": "last_boot_time_nix.sh", "requirements": ["shell"]},
{"name": "last_boot_time_win.ps1", "requirements": ["powershell"]},
]
parsed = {"implementations": items}
candidate = LoopCandidate(
path=("implementations",),
loop_var="implementation",
items=items,
item_schema="simple_dict",
)
handler = JsonHandler()
template = handler.generate_jinja2_template_with_loops(
parsed, "fact", None, [candidate]
)
env = Environment(keep_trailing_newline=True)
env.filters["to_json"] = lambda value, **kwargs: json.dumps(value, **kwargs)
rendered = env.from_string(template).render(
fact_implementations=candidate.safe_items()
)
assert "}, {" not in rendered
assert json.loads(rendered) == parsed
def test_json_scalar_span_collector_empty_containers_and_invalid_json():
handler = JsonHandler()
assert handler._collect_json_scalar_spans('{"empty": [], "obj": {}}') == []
assert handler._collect_json_scalar_spans('{"unterminated": [1,}') is None
def test_json_template_falls_back_when_source_scanner_cannot_collect_spans(monkeypatch):
handler = JsonHandler()
monkeypatch.setattr(handler, "_collect_json_scalar_spans", lambda _text: None)
template = handler.generate_jinja2_template(
{"answer": 42}, "role", original_text='{"answer": 42}'
)
assert (
template
== '{\n "answer": {{ role_answer | to_json(ensure_ascii=False) }}\n}\n'
)
def test_generate_jinja2_template_json_type_error():
"""
Wrong type for JSON in JsonHandler.generate_jinja2_template should raise TypeError.
"""
handler = JsonHandler()
with pytest.raises(TypeError):
handler.generate_jinja2_template(parsed="not a dict", role_prefix="role")