Public Access
DEVX-152: feat: sync missing features from v0.49.x line to master
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"""Unit tests for devx.tools.check_jinja_expr.
|
||||
|
||||
Verifies that the check correctly validates Jinja2 expressions,
|
||||
catches reversed strftime filter arguments (the OBL-INFRA-508 bug),
|
||||
and passes on valid expressions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_jinja_expr import (
|
||||
_check_file,
|
||||
_default_ansible_dirs,
|
||||
_extract_expressions,
|
||||
_render_expression,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
def test_render_valid_expression():
|
||||
"""Valid Jinja expression renders without error."""
|
||||
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime(1735689600)")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_reversed_strftime_args():
|
||||
"""Reversed strftime filter args are detected as an error."""
|
||||
ok, msg = _render_expression("(now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00')")
|
||||
assert not ok
|
||||
assert "reversed" in msg.lower()
|
||||
|
||||
|
||||
def test_render_correct_strftime_args():
|
||||
"""Correct strftime filter args pass."""
|
||||
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime((now().timestamp() | int) + 3600)")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_unknown_filter():
|
||||
"""Unknown filter is reported as an error."""
|
||||
ok, msg = _render_expression("'test' | nonexistent_filter")
|
||||
assert not ok
|
||||
assert "filter" in msg.lower()
|
||||
|
||||
|
||||
def test_extract_skips_go_templates():
|
||||
"""Go template syntax ({{.Field}}) is not extracted."""
|
||||
content = "cmd: docker inspect --format '{{.State.Running}}' container"
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_single_char():
|
||||
"""Single-character fragments are not extracted."""
|
||||
content = 'value: "{{ \' }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_multiline():
|
||||
"""Multi-line expressions are skipped."""
|
||||
content = 'value: "{{\n something\n}}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced():
|
||||
"""Expressions with unbalanced braces (from partial capture) are skipped."""
|
||||
content = "value: \"{{ default({'k': {}}, true) }}\""
|
||||
expressions = _extract_expressions(content)
|
||||
# The regex captures {{ default({'k': {}} — unbalanced parens
|
||||
# because the inner }} terminates the match early.
|
||||
# All extracted expressions should have balanced braces.
|
||||
for expr in expressions:
|
||||
assert expr.count("{") == expr.count("}")
|
||||
|
||||
|
||||
def test_extract_valid_expression():
|
||||
"""Valid Jinja expressions are extracted."""
|
||||
content = "value: \"{{ my_var | default('x') }}\""
|
||||
expressions = _extract_expressions(content)
|
||||
assert "my_var | default('x')" in expressions
|
||||
|
||||
|
||||
def test_main_passes_on_clean_file(tmp_path: Path) -> None:
|
||||
"""A file with valid expressions passes."""
|
||||
test_file = tmp_path / "tasks.yml"
|
||||
test_file.write_text("value: \"{{ my_var | default('x') }}\"\nother: \"{{ '%Y' | strftime(1735689600) }}\"\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(test_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_no_violations_empty_dir(tmp_path: Path) -> None:
|
||||
"""An empty directory passes."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_catches_reversed_strftime(tmp_path: Path) -> None:
|
||||
"""A file with reversed strftime args is flagged."""
|
||||
test_file = tmp_path / "test.yml"
|
||||
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(test_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "reversed" in result.output.lower()
|
||||
|
||||
|
||||
def test_render_skips_undefined_var():
|
||||
"""Undefined variables are skipped (MockDict returns mock for missing keys)."""
|
||||
ok, _ = _render_expression("nonexistent_var_in_mock | upper")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_skips_other_errors():
|
||||
"""Non-filter errors from missing mocks are skipped."""
|
||||
ok, _ = _render_expression("some_undefined.attr.method()")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_extract_skips_backtick():
|
||||
"""Backtick fragments are skipped (caught by single-char check)."""
|
||||
content = 'value: "{{ ` }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_network_settings():
|
||||
"""Expressions with .NetworkSettings. patterns are skipped."""
|
||||
content = 'value: "{{ foo.NetworkSettings.IPAddress }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced_parens():
|
||||
"""Expressions with unbalanced parens are skipped."""
|
||||
content = 'value: "{{ foo(bar }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced_braces():
|
||||
"""Expressions with unbalanced braces are skipped."""
|
||||
content = 'value: "{{ foo{bar }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced_brackets():
|
||||
"""Expressions with unbalanced brackets are skipped."""
|
||||
content = 'value: "{{ foo[0 }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_control_flow():
|
||||
"""Control flow fragments starting with % are skipped."""
|
||||
content = 'value: "{{ % if x }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_check_file_outside_repo(tmp_path: Path) -> None:
|
||||
"""Files outside REPO_ROOT are handled (no relative_to error)."""
|
||||
test_file = tmp_path / "test.yml"
|
||||
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
|
||||
violations = _check_file(test_file, Path("/other/repo"))
|
||||
assert len(violations) == 1
|
||||
assert "reversed" in violations[0].lower()
|
||||
|
||||
|
||||
def test_render_mock_dict_missing_key():
|
||||
"""MockDict returns a mock for missing keys (no UndefinedError)."""
|
||||
ok, _ = _render_expression("undefined_var.some_attr | upper")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_syntax_error():
|
||||
"""Syntax errors are reported as failures."""
|
||||
ok, msg = _render_expression("{{ invalid syntax +")
|
||||
assert not ok
|
||||
assert "Syntax error" in msg
|
||||
|
||||
|
||||
def test_render_unknown_filter_error():
|
||||
"""Unknown filters are reported as failures (not skipped)."""
|
||||
ok, msg = _render_expression("'test' | nonexistent_filter")
|
||||
assert not ok
|
||||
assert "filter" in msg.lower()
|
||||
|
||||
|
||||
def test_render_generic_exception_skipped():
|
||||
"""Non-filter exceptions from missing mocks are skipped."""
|
||||
# replace() with no args triggers TypeError (missing required args)
|
||||
# which is not a filter-not-found or strftime error — should be skipped.
|
||||
ok, msg = _render_expression("my_var | replace")
|
||||
assert ok
|
||||
assert "Skipped" in msg
|
||||
|
||||
|
||||
def test_default_ansible_dirs():
|
||||
"""_default_ansible_dirs returns playbooks and roles paths."""
|
||||
dirs = _default_ansible_dirs()
|
||||
assert Path.cwd() / "ansible" / "playbooks" in dirs
|
||||
assert Path.cwd() / "ansible" / "roles" in dirs
|
||||
|
||||
|
||||
def test_main_default_dirs(tmp_path: Path) -> None:
|
||||
"""Running with no --path scans default dirs (uses small temp fixture)."""
|
||||
(tmp_path / "playbooks").mkdir()
|
||||
(tmp_path / "roles").mkdir()
|
||||
(tmp_path / "playbooks" / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
|
||||
with patch(
|
||||
"devx.tools.check_jinja_expr._default_ansible_dirs",
|
||||
return_value=[tmp_path / "playbooks", tmp_path / "roles"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_custom_ansible_dirs(tmp_path: Path) -> None:
|
||||
"""--ansible-dir option works."""
|
||||
(tmp_path / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_extract_skips_println():
|
||||
"""Expressions with 'println' (Go template) are skipped."""
|
||||
content = 'value: "{{ println something }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_state_dot():
|
||||
"""Expressions with .State. patterns are skipped."""
|
||||
content = 'value: "{{ foo.State.Running }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_render_now_with_format():
|
||||
"""now() with a format argument works."""
|
||||
ok, _ = _render_expression("now('%Y-%m-%d')")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_find_yaml_files_skips_molecule(tmp_path: Path) -> None:
|
||||
"""Molecule directories are excluded from file search."""
|
||||
from devx.tools.check_jinja_expr import _find_yaml_files
|
||||
|
||||
(tmp_path / "tasks.yml").write_text("value: test\n")
|
||||
(tmp_path / "molecule").mkdir()
|
||||
(tmp_path / "molecule" / "test.yml").write_text("value: test\n")
|
||||
files = _find_yaml_files(tmp_path)
|
||||
assert all("molecule" not in f.parts for f in files)
|
||||
Reference in New Issue
Block a user