"""Unit tests for devx.molecule.molecule_changed. Verifies that the script correctly detects changed roles, fails open on unmapped ansible paths, and only emits roles that exist on disk. """ from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from click.testing import CliRunner from devx.molecule.molecule_changed import ( detect_changed_roles, get_changed_files, main, role_to_target, roles_to_targets, ) TESTABLE_ROLES = ("docker_base", "app_container", "restore", "observability") @pytest.fixture() def roles_dir(tmp_path: Path) -> Path: """Fake roles dir: 4 testable roles (molecule/ present) + 1 untestable.""" for role in TESTABLE_ROLES: (tmp_path / role / "molecule" / "default").mkdir(parents=True) (tmp_path / "untested_role").mkdir() # exists but no molecule/ dir return tmp_path def test_detect_role_change(roles_dir: Path): """A file in ansible/roles// maps to that role.""" roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], roles_dir) assert roles == {"docker_base"} def test_detect_role_not_on_disk_is_dropped(roles_dir: Path): """Changed role absent from roles_dir selects nothing (REQ-2).""" roles = detect_changed_roles(["ansible/roles/sso_config/tasks/main.yml"], roles_dir) assert roles == set() def test_detect_playbook_change(roles_dir: Path): """A mapped playbook maps to its included roles, filtered to disk.""" files = ["ansible/playbooks/deploy-observability.yml"] roles = detect_changed_roles(files, roles_dir) # zitadel + crowdsec are mapped but absent from the fake roles dir. assert roles == {"observability", "docker_base"} def test_detect_shared_infra_triggers_all(roles_dir: Path): """ansible.cfg change triggers all testable roles only.""" roles = detect_changed_roles(["ansible/ansible.cfg"], roles_dir) assert roles == set(TESTABLE_ROLES) def test_detect_molecule_shared_path(roles_dir: Path): """ansible/molecule/ change triggers all testable roles.""" roles = detect_changed_roles(["ansible/molecule/Dockerfile"], roles_dir) assert roles == set(TESTABLE_ROLES) def test_detect_requirements_yml_triggers_all(roles_dir: Path): """ansible/requirements.yml change triggers all testable roles.""" roles = detect_changed_roles(["ansible/requirements.yml"], roles_dir) assert roles == set(TESTABLE_ROLES) def test_detect_group_vars_triggers_all(roles_dir: Path): """ansible/group_vars/ change triggers all testable roles (REQ-1).""" roles = detect_changed_roles(["ansible/group_vars/all/images.yml"], roles_dir) assert roles == set(TESTABLE_ROLES) def test_unmapped_playbook_fails_open(roles_dir: Path): """An unmapped playbook selects all testable roles (REQ-1).""" roles = detect_changed_roles(["ansible/playbooks/new-deploy.yml"], roles_dir) assert roles == set(TESTABLE_ROLES) def test_playbook_tasks_dir_fails_open(roles_dir: Path): """Shared playbook task files select all testable roles (REQ-1).""" roles = detect_changed_roles(["ansible/playbooks/_tasks/upgrade-postgres-database.yml"], roles_dir) assert roles == set(TESTABLE_ROLES) def test_mapped_playbooks(roles_dir: Path): """Each newly mapped playbook selects its roles (REQ-1).""" expectations = { "ansible/playbooks/restore.yml": {"restore"}, "ansible/playbooks/upgrade-postgres.yml": {"app_container"}, "ansible/playbooks/rolling-update-gitea.yml": {"app_container"}, "ansible/playbooks/update-alertmanager.yml": {"observability"}, "ansible/playbooks/build-image.yml": {"docker_base"}, "ansible/playbooks/deploy-sso-bridge.yml": set(), } for playbook, expected in expectations.items(): assert detect_changed_roles([playbook], roles_dir) == expected, playbook def test_detect_prepare_vms_playbook(roles_dir: Path): """prepare-vms.yml maps to all base roles present on disk.""" roles = detect_changed_roles(["ansible/playbooks/prepare-vms.yml"], roles_dir) assert roles == {"docker_base"} def test_detect_deploy_customer_playbook(roles_dir: Path): """deploy-customer.yml maps to its roles present on disk.""" roles = detect_changed_roles(["ansible/playbooks/deploy-customer.yml"], roles_dir) assert roles == {"app_container", "docker_base"} def test_detect_no_ansible_changes(roles_dir: Path): """Non-Ansible files don't trigger any roles.""" roles = detect_changed_roles(["scripts/x.py", "Makefile"], roles_dir) assert roles == set() def test_detect_environments_not_molecule_covered(roles_dir: Path): """ansible/environments/ data is not molecule-covered (documented).""" roles = detect_changed_roles(["ansible/environments/staging/customers.yml"], roles_dir) assert roles == set() def test_detect_missing_roles_dir(): """A nonexistent roles_dir yields no roles (honest: nothing testable).""" roles = detect_changed_roles(["ansible/roles/docker_base/tasks/main.yml"], "/nonexistent") assert roles == set() def test_role_to_target(): """Role names map to conventional make targets.""" assert role_to_target("docker_base") == "molecule-docker-base" assert role_to_target("app_hardening") == "molecule-app-hardening" def test_roles_to_targets(): """Role names map to make targets.""" targets = roles_to_targets({"docker_base", "zitadel"}) assert targets == ["molecule-docker-base", "molecule-zitadel"] def test_main_no_changes(): """When no files changed, outputs message to stderr.""" with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]): result = CliRunner().invoke(main, ["--print-targets"]) assert result.exit_code == 0 assert "No changed files" in result.output def test_main_print_targets(roles_dir: Path): """--print-targets outputs make targets for existing roles.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["ansible/roles/docker_base/tasks/main.yml"], ): result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 assert "molecule-docker-base" in result.output def test_main_print_roles(roles_dir: Path): """--print-roles outputs role names.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["ansible/roles/restore/tasks/main.yml"], ): result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 assert "restore" in result.output def test_main_no_ansible_changes(roles_dir: Path): """When only non-Ansible files changed, outputs no scenarios message.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["scripts/molecule_changed.py"], ): result = CliRunner().invoke(main, ["--print-targets", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 assert "No molecule scenarios" in result.output def test_get_changed_files_with_mock(): """get_changed_files returns files from git diff.""" with patch("devx.molecule.molecule_changed._run_git", return_value="file1\nfile2\n"): files = get_changed_files("origin/master") assert files == ["file1", "file2"] def test_get_changed_files_falls_back_to_master(): """When base ref has no diff, falls back to master.""" calls: list[list[str]] = [] def mock_git(args): calls.append(args) if "origin/master...HEAD" in args[2]: return "" return "ansible/roles/docker_base/tasks/main.yml\n" with patch("devx.molecule.molecule_changed._run_git", side_effect=mock_git): files = get_changed_files("origin/master") assert files == ["ansible/roles/docker_base/tasks/main.yml"] assert len(calls) == 2 def test_get_changed_files_empty(): """When no changes in either ref, returns empty list.""" with patch("devx.molecule.molecule_changed._run_git", return_value=""): files = get_changed_files("origin/master") assert files == [] def test_main_default_base(roles_dir: Path): """main() with no --base uses origin/master.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["ansible/roles/restore/tasks/main.yml"], ) as mock: result = CliRunner().invoke(main, ["--print-roles", "--roles-dir", str(roles_dir)]) assert result.exit_code == 0 mock.assert_called_once_with("origin/master")