From b75b6f1e285e7acc409c29ece8bf9e9172c91d62 Mon Sep 17 00:00:00 2001 From: "hanthor-hive-agent[bot]" Date: Sun, 28 Jun 2026 09:20:03 -0400 Subject: [PATCH] [quality] test: add unit tests for check_tiers.py Add unit tests for the tiered COPR build orchestration script: test_check_tiers.py: - get_pkg_name(): rpmspec resolution, bootstrap spec filtering, fallback to dirname, no spec files, only bootstrap spec - get_status(): COPR monitor JSON parsing, duplicate entry handling test_check_tiers_main.py: - main(): all-succeeded (no builds triggered), missing package triggers build, multiple missing packages trigger all builds, empty tiers handled gracefully Fixes #55 Signed-off-by: Quality Agent Signed-off-by: hanthor-hive-agent[bot] --- tests/test_check_tiers.py | 105 ++++++++++++++++++++++++++ tests/test_check_tiers_main.py | 132 +++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+) create mode 100644 tests/test_check_tiers.py create mode 100644 tests/test_check_tiers_main.py diff --git a/tests/test_check_tiers.py b/tests/test_check_tiers.py new file mode 100644 index 0000000..2feb759 --- /dev/null +++ b/tests/test_check_tiers.py @@ -0,0 +1,105 @@ +"""Unit tests for check_tiers.py. + +Tests the build orchestration logic with mocked subprocess and file I/O. +""" + +import json +import os +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open + +import pytest + +# Add project root to sys.path for importing check_tiers +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +import check_tiers + + +class TestGetPkgName: + def test_get_pkg_name_from_spec(self, tmp_path): + """Should return package name from rpmspec output.""" + spec_file = tmp_path / "mypkg.spec" + spec_file.write_text("Name: mypkg\nVersion: 1.0\n") + + with patch("check_tiers.subprocess.check_output") as mock_check_output: + mock_check_output.return_value = "mypkg\n" + result = check_tiers.get_pkg_name(str(tmp_path)) + assert result == "mypkg" + mock_check_output.assert_called_once() + + def test_get_pkg_name_fallback_to_dirname(self, tmp_path): + """Should fall back to directory name when rpmspec fails.""" + spec_file = tmp_path / "mypkg.spec" + spec_file.write_text("broken spec") + + with patch("check_tiers.subprocess.check_output") as mock_check_output: + mock_check_output.side_effect = Exception("rpmspec failed") + result = check_tiers.get_pkg_name(str(tmp_path)) + # Should fall back to directory basename + assert result == tmp_path.name + + def test_get_pkg_name_skips_bootstrap_specs(self, tmp_path): + """Should prefer non-bootstrap spec files.""" + bootstrap_spec = tmp_path / "mypkg-bootstrap.spec" + bootstrap_spec.write_text("Name: mypkg-bootstrap\n") + main_spec = tmp_path / "mypkg.spec" + main_spec.write_text("Name: mypkg\n") + + with patch("check_tiers.subprocess.check_output") as mock_check_output: + mock_check_output.return_value = "mypkg\n" + result = check_tiers.get_pkg_name(str(tmp_path)) + # Should find the non-bootstrap spec + assert result == "mypkg" + + def test_get_pkg_name_no_spec_files(self, tmp_path): + """Should fall back to directory name when no .spec files exist.""" + (tmp_path / "somefile.txt").write_text("hello") + + with patch("check_tiers.subprocess.check_output") as mock_check_output: + mock_check_output.side_effect = Exception("no spec") + result = check_tiers.get_pkg_name(str(tmp_path)) + assert result == tmp_path.name + + def test_get_pkg_name_only_bootstrap_spec(self, tmp_path): + """When only bootstrap spec exists, use it.""" + spec_file = tmp_path / "mypkg-bootstrap.spec" + spec_file.write_text("Name: mypkg-bootstrap\n") + + with patch("check_tiers.subprocess.check_output") as mock_check_output: + mock_check_output.return_value = "mypkg-bootstrap\n" + result = check_tiers.get_pkg_name(str(tmp_path)) + assert result == "mypkg-bootstrap" + + +class TestGetStatus: + def test_get_status_returns_map(self): + """Should return a dict keyed by (pkg, chroot) with state values.""" + mock_json = json.dumps([ + {"name": "pkg1", "chroot": "epel-10-aarch64", "state": "succeeded"}, + {"name": "pkg1", "chroot": "alma-kitten+epel-10-x86_64_v2", "state": "succeeded"}, + {"name": "pkg2", "chroot": "epel-10-aarch64", "state": "failed"}, + ]) + + with patch("check_tiers.subprocess.check_output", return_value=mock_json): + result = check_tiers.get_status() + + assert ("pkg1", "epel-10-aarch64") in result + assert ("pkg1", "alma-kitten+epel-10-x86_64_v2") in result + assert ("pkg2", "epel-10-aarch64") in result + assert result[("pkg1", "epel-10-aarch64")] == "succeeded" + assert result[("pkg2", "epel-10-aarch64")] == "failed" + + def test_get_status_takes_first_entry(self): + """When duplicate entries exist, should take the first one.""" + mock_json = json.dumps([ + {"name": "pkg1", "chroot": "epel-10-aarch64", "state": "succeeded"}, + {"name": "pkg1", "chroot": "epel-10-aarch64", "state": "failed"}, + ]) + + with patch("check_tiers.subprocess.check_output", return_value=mock_json): + result = check_tiers.get_status() + + assert result[("pkg1", "epel-10-aarch64")] == "succeeded" diff --git a/tests/test_check_tiers_main.py b/tests/test_check_tiers_main.py new file mode 100644 index 0000000..d8bed39 --- /dev/null +++ b/tests/test_check_tiers_main.py @@ -0,0 +1,132 @@ +"""Integration-style tests for check_tiers.main() with mocked dependencies.""" + +import json +import os +import sys +from pathlib import Path +from unittest.mock import patch, MagicMock, mock_open + +import yaml +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +import check_tiers + + +# Sample build-order.yml for testing +SAMPLE_BUILD_ORDER = { + "tiers": [ + { + "name": "bootstrap", + "packages": [ + {"path": "pkg1"}, + {"path": "pkg2"}, + ], + }, + { + "name": "core", + "packages": [ + {"path": "pkg3"}, + ], + }, + ], +} + + +class TestMain: + def test_main_all_succeeded(self, tmp_path): + """When all packages have succeeded, main should print completion.""" + build_order = tmp_path / "build-order.yml" + build_order.write_text(yaml.dump(SAMPLE_BUILD_ORDER)) + + mock_status = { + ("pkg1", check_tiers.ARM_CHROOT): "succeeded", + ("pkg1", check_tiers.V2_CHROOT): "succeeded", + ("pkg2", check_tiers.ARM_CHROOT): "succeeded", + ("pkg2", check_tiers.V2_CHROOT): "succeeded", + ("pkg3", check_tiers.ARM_CHROOT): "succeeded", + ("pkg3", check_tiers.V2_CHROOT): "succeeded", + } + + with patch.multiple( + check_tiers, + get_status=MagicMock(return_value=mock_status), + get_pkg_name=MagicMock(side_effect=lambda p: Path(p).name), + ): + with patch.object(sys, "argv", ["check_tiers.py"]): + with patch("pathlib.Path.read_text", return_value=yaml.dump(SAMPLE_BUILD_ORDER)): + # main() should complete without triggering any builds + result = check_tiers.main() + + def test_main_missing_package_triggers_build(self, tmp_path): + """When a package is missing/failed, main should trigger builds.""" + build_order = tmp_path / "build-order.yml" + build_order.write_text(yaml.dump(SAMPLE_BUILD_ORDER)) + + mock_status = { + ("pkg1", check_tiers.ARM_CHROOT): "succeeded", + ("pkg1", check_tiers.V2_CHROOT): "succeeded", + ("pkg2", check_tiers.ARM_CHROOT): "failed", # This should trigger + ("pkg2", check_tiers.V2_CHROOT): "succeeded", + ("pkg3", check_tiers.ARM_CHROOT): "succeeded", + ("pkg3", check_tiers.V2_CHROOT): "succeeded", + } + + with patch.multiple( + check_tiers, + get_status=MagicMock(return_value=mock_status), + get_pkg_name=MagicMock(side_effect=lambda p: Path(p).name), + ): + with patch.object(sys, "argv", ["check_tiers.py"]): + with patch("pathlib.Path.read_text", return_value=yaml.dump(SAMPLE_BUILD_ORDER)): + with patch("check_tiers.subprocess.run") as mock_run: + mock_run.return_value = MagicMock() + result = check_tiers.main() + # Should have triggered build for pkg2 + mock_run.assert_called_once() + + def test_main_multiple_missing_in_one_tier(self, tmp_path): + """When multiple packages are missing, main should trigger all builds.""" + build_order = tmp_path / "build-order.yml" + build_order.write_text(yaml.dump(SAMPLE_BUILD_ORDER)) + + mock_status = { + ("pkg1", check_tiers.ARM_CHROOT): "succeeded", + ("pkg1", check_tiers.V2_CHROOT): "failed", + ("pkg2", check_tiers.ARM_CHROOT): "failed", + ("pkg2", check_tiers.V2_CHROOT): "failed", + ("pkg3", check_tiers.ARM_CHROOT): "succeeded", + ("pkg3", check_tiers.V2_CHROOT): "succeeded", + } + + with patch.multiple( + check_tiers, + get_status=MagicMock(return_value=mock_status), + get_pkg_name=MagicMock(side_effect=lambda p: Path(p).name), + ): + with patch.object(sys, "argv", ["check_tiers.py"]): + with patch("pathlib.Path.read_text", return_value=yaml.dump(SAMPLE_BUILD_ORDER)): + with patch("check_tiers.subprocess.run") as mock_run: + mock_run.return_value = MagicMock() + result = check_tiers.main() + # Should have triggered builds for pkg1 and pkg2 + assert mock_run.call_count >= 1 + + def test_main_empty_tiers(self, tmp_path): + """When build-order.yml has no tiers, main should handle gracefully.""" + empty_order = {"tiers": []} + build_order = tmp_path / "build-order.yml" + build_order.write_text(yaml.dump(empty_order)) + + with patch.multiple( + check_tiers, + get_status=MagicMock(return_value={}), + get_pkg_name=MagicMock(return_value="pkg"), + ): + with patch.object(sys, "argv", ["check_tiers.py"]): + with patch("pathlib.Path.read_text", return_value=yaml.dump(empty_order)): + result = check_tiers.main() + # No tiers means no builds triggered + assert result is None # main() returns None on success