"""Unit tests for devx.molecule.molecule_changed. Verifies that the script correctly detects changed roles and maps them to make targets. """ from __future__ import annotations from unittest.mock import patch from click.testing import CliRunner from devx.molecule.molecule_changed import ( detect_changed_roles, get_changed_files, main, roles_to_targets, ) def test_detect_role_change(): """A file in ansible/roles// maps to that role.""" files = ["ansible/roles/docker_base/tasks/main.yml"] roles = detect_changed_roles(files) assert "docker_base" in roles def test_detect_playbook_change(): """A playbook change maps to its included roles.""" files = ["ansible/playbooks/deploy-observability.yml"] roles = detect_changed_roles(files) assert "observability" in roles assert "docker_base" in roles assert "zitadel" in roles def test_detect_shared_infra_triggers_all(): """ansible.cfg change triggers all roles.""" files = ["ansible/ansible.cfg"] roles = detect_changed_roles(files) assert len(roles) == 10 # all roles def test_detect_no_ansible_changes(): """Non-Ansible files don't trigger any roles.""" files = ["scripts/molecule_changed.py", "Makefile"] roles = detect_changed_roles(files) assert len(roles) == 0 def test_roles_to_targets(): """Role names map to make targets.""" targets = roles_to_targets({"docker_base", "zitadel"}) assert "molecule-docker-base" in targets assert "molecule-zitadel" in targets def test_roles_to_targets_unknown_role(): """Unknown roles are silently skipped.""" targets = roles_to_targets({"docker_base", "unknown_role"}) assert targets == ["molecule-docker-base"] def test_main_no_changes(): """When no files changed, outputs message to stderr.""" with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]): runner = CliRunner() result = runner.invoke(main, ["--print-targets"]) assert result.exit_code == 0 assert "No changed files" in result.output def test_main_print_targets(): """--print-targets outputs make targets.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["ansible/roles/docker_base/tasks/main.yml"], ): runner = CliRunner() result = runner.invoke(main, ["--print-targets"]) assert result.exit_code == 0 assert "molecule-docker-base" in result.output def test_main_print_roles(): """--print-roles outputs role names.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["ansible/roles/zitadel/tasks/main.yml"], ): runner = CliRunner() result = runner.invoke(main, ["--print-roles"]) assert result.exit_code == 0 assert "zitadel" in result.output def test_main_no_ansible_changes(): """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"], ): runner = CliRunner() result = runner.invoke(main, ["--print-targets"]) 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) # First call (origin/master) returns empty, second (master) returns files 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_detect_molecule_shared_path(): """ansible/molecule/ change triggers all roles.""" files = ["ansible/molecule/Dockerfile"] roles = detect_changed_roles(files) assert len(roles) == 10 def test_detect_requirements_yml_triggers_all(): """ansible/requirements.yml change triggers all roles.""" files = ["ansible/requirements.yml"] roles = detect_changed_roles(files) assert len(roles) == 10 def test_detect_configure_oidc_playbook(): """configure-oidc.yml maps to sso_config and app_container.""" files = ["ansible/playbooks/configure-oidc.yml"] roles = detect_changed_roles(files) assert "sso_config" in roles assert "app_container" in roles def test_detect_prepare_vms_playbook(): """prepare-vms.yml maps to all base roles.""" files = ["ansible/playbooks/prepare-vms.yml"] roles = detect_changed_roles(files) assert "docker_base" in roles assert "app_hardening" in roles assert "storage" in roles assert "disk_cleanup" in roles assert "crowdsec" in roles def test_detect_deploy_customer_playbook(): """deploy-customer.yml maps to its roles.""" files = ["ansible/playbooks/deploy-customer.yml"] roles = detect_changed_roles(files) assert "app_container" in roles assert "docker_base" in roles assert "app_hardening" in roles assert "sso_config" in roles def test_main_default_base(): """main() with no --base uses origin/master.""" with patch( "devx.molecule.molecule_changed.get_changed_files", return_value=["ansible/roles/zitadel/tasks/main.yml"], ) as mock: runner = CliRunner() result = runner.invoke(main, ["--print-roles"]) assert result.exit_code == 0 mock.assert_called_once_with("origin/master")