"""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)