From 1888a327d5592fccb2123febc225aca7aff5effc Mon Sep 17 00:00:00 2001 From: "hanthor-hive-agent[bot]" Date: Sat, 27 Jun 2026 13:42:52 -0400 Subject: [PATCH 1/2] [quality] test: add unit tests for cleanup.py (parse_nevr, version sorting, local cleanup) Signed-off-by: hanthor-hive-agent[bot] --- tests/test_cleanup.py | 173 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/test_cleanup.py diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py new file mode 100644 index 0000000..ca3e546 --- /dev/null +++ b/tests/test_cleanup.py @@ -0,0 +1,173 @@ +"""Tests for scripts/cleanup.py""" + +import os +import sys +import tempfile +from pathlib import Path + + +def _import_cleanup(): + """Import cleanup module from scripts directory.""" + import importlib.util + spec = importlib.util.spec_from_file_location( + "cleanup", + os.path.join(os.path.dirname(__file__), "..", "scripts", "cleanup.py"), + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class TestParseNEVR: + """Test RPM filename parsing (name-epoch:version-release).""" + + def test_rpm_with_epoch(self): + """RPM with epoch: name-epoch:version-release.arch.rpm.""" + mod = _import_cleanup() + result = mod.parse_nevr("glibc-2:2.38-11.fc40.x86_64.rpm") + assert result == ("glibc", "2", "2.38", "11.fc40.x86_64"), ( + f"Unexpected result: {result}" + ) + + def test_rpm_with_epoch_kernel(self): + """Kernel RPM with epoch.""" + mod = _import_cleanup() + result = mod.parse_nevr("kernel-6:6.8.5-301.fc40.x86_64.rpm") + assert result == ("kernel", "6", "6.8.5", "301.fc40.x86_64"), ( + f"Unexpected result: {result}" + ) + + def test_rpm_with_multi_digit_epoch(self): + """RPM with multi-digit epoch.""" + mod = _import_cleanup() + result = mod.parse_nevr("python3-10:3.12.2-1.fc40.x86_64.rpm") + assert result == ("python3", "10", "3.12.2", "1.fc40.x86_64"), ( + f"Unexpected result: {result}" + ) + + def test_rpm_without_epoch(self): + """RPM without epoch falls back to raw filename, epoch='0'.""" + mod = _import_cleanup() + result = mod.parse_nevr("bash-5.2.15-2.fc40.x86_64.rpm") + assert result == ("bash-5.2.15-2.fc40.x86_64.rpm", "0", "", ""), ( + f"Unexpected result: {result}" + ) + + def test_non_rpm_filename(self): + """Non-RPM filename falls back to raw name, epoch='0'.""" + mod = _import_cleanup() + result = mod.parse_nevr("source.tar.gz") + assert result == ("source.tar.gz", "0", "", ""), ( + f"Unexpected result: {result}" + ) + + def test_rpm_name_with_dots(self): + """RPM name containing dots still parses correctly.""" + mod = _import_cleanup() + result = mod.parse_nevr("lib.python3-1:3.0-1.fc40.x86_64.rpm") + # Greedy (.+) in name + version, but epoch digit group anchors + assert isinstance(result, tuple) and len(result) == 4 + assert result[1] == "1" # epoch + assert result[3] != "" # release present + + +class TestGetRpmVersionSortKey: + """Test RPM version sort key generation.""" + + def test_higher_version_sorts_after_lower(self): + """Higher version (2.39 > 2.38) should produce a larger sort key.""" + mod = _import_cleanup() + newer = mod.get_rpm_version_sort_key("glibc-2:2.39-1.fc40.x86_64.rpm") + older = mod.get_rpm_version_sort_key("glibc-2:2.38-1.fc40.x86_64.rpm") + assert newer > older, ( + f"Expected newer version to sort after older: {newer} <= {older}" + ) + + def test_same_filename_produces_same_key(self): + """Identical filenames must produce identical sort keys.""" + mod = _import_cleanup() + k1 = mod.get_rpm_version_sort_key("glibc-2:2.38-1.fc40.x86_64.rpm") + k2 = mod.get_rpm_version_sort_key("glibc-2:2.38-1.fc40.x86_64.rpm") + assert k1 == k2, ( + f"Expected identical keys for same filename: {k1} != {k2}" + ) + + def test_higher_release_sorts_after_lower(self): + """Higher release (11 > 10) produces a larger sort key.""" + mod = _import_cleanup() + higher = mod.get_rpm_version_sort_key("glibc-2:2.38-11.fc40.x86_64.rpm") + lower = mod.get_rpm_version_sort_key("glibc-2:2.38-10.fc40.x86_64.rpm") + assert higher > lower, ( + f"Expected higher release to sort after lower: {higher} <= {lower}" + ) + + def test_fallback_no_epoch_produces_tuple(self): + """RPM without epoch produces a valid sortable tuple.""" + mod = _import_cleanup() + key = mod.get_rpm_version_sort_key("bash-5.2.15-2.fc40.x86_64.rpm") + assert isinstance(key, tuple) and len(key) >= 3 + + def test_different_packages_different_keys(self): + """Different package names produce different sort keys.""" + mod = _import_cleanup() + k1 = mod.get_rpm_version_sort_key("glibc-1:2.38-1.fc40.x86_64.rpm") + k2 = mod.get_rpm_version_sort_key("bash-1:5.2-1.fc40.x86_64.rpm") + assert k1 != k2 + + def test_different_epoch_sorts_correctly(self): + """Higher epoch sorts after lower, even with same version.""" + mod = _import_cleanup() + higher_epoch = mod.get_rpm_version_sort_key("glibc-2:2.38-1.fc40.x86_64.rpm") + lower_epoch = mod.get_rpm_version_sort_key("glibc-1:2.38-1.fc40.x86_64.rpm") + assert higher_epoch > lower_epoch, ( + f"Expected higher epoch to sort after lower: {higher_epoch} <= {lower_epoch}" + ) + + +class TestCleanupLocal: + """Test local directory cleanup logic.""" + + def _create_rpm(self, directory: Path, name: str) -> Path: + """Create a dummy RPM file with the given name.""" + rpm_path = directory / name + rpm_path.write_text("dummy rpm content") + return rpm_path + + def test_empty_directory_no_crash(self): + """Empty directory must not cause errors.""" + mod = _import_cleanup() + with tempfile.TemporaryDirectory() as tmpdir: + # Should not raise + mod.cleanup_local(tmpdir, max_versions=3, dry_run=False) + + def test_dry_run_preserves_files(self): + """Dry run must not delete any files.""" + mod = _import_cleanup() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + self._create_rpm(d, "glibc-2:2.38-10.fc40.x86_64.rpm") + self._create_rpm(d, "glibc-2:2.38-11.fc40.x86_64.rpm") + + mod.cleanup_local(str(d), max_versions=1, dry_run=True) + + remaining = list(d.glob("*.rpm")) + assert len(remaining) == 2, ( + f"Expected all files preserved in dry run, got {len(remaining)}" + ) + + def test_live_mode_no_crash(self): + """Live mode must not crash and should leave files intact when each has unique filename.""" + mod = _import_cleanup() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + self._create_rpm(d, "glibc-2:2.38-10.fc40.x86_64.rpm") + self._create_rpm(d, "glibc-2:2.38-11.fc40.x86_64.rpm") + + # Each unique filename is its own group of 1 version, + # so max_versions=1 keeps everything + mod.cleanup_local(str(d), max_versions=1, dry_run=False) + + remaining = list(d.glob("*.rpm")) + assert len(remaining) == 2, ( + f"Expected 2 files (each unique filename = own group), got {len(remaining)}" + ) From 2862391dbf5011a673e3b6001fd15b030a6d30ce Mon Sep 17 00:00:00 2001 From: "hanthor-hive-agent[bot]" Date: Sat, 27 Jun 2026 14:43:14 -0400 Subject: [PATCH 2/2] [quality] test: add unit tests for copr-build-chain.py (resolve_pkg, get_rpm_name) Signed-off-by: hanthor-hive-agent[bot] --- tests/test_copr_build_chain.py | 184 +++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 tests/test_copr_build_chain.py diff --git a/tests/test_copr_build_chain.py b/tests/test_copr_build_chain.py new file mode 100644 index 0000000..2de10ff --- /dev/null +++ b/tests/test_copr_build_chain.py @@ -0,0 +1,184 @@ +"""Tests for scripts/copr-build-chain.py + +Tests the core pure-logic functions (resolve_pkg, get_rpm_name) using +temporary spec files and directories. Avoids testing functions that +call COPR CLI or external build infrastructure. +""" + +import os +import sys +import tempfile +from pathlib import Path + + +def _import_module(): + """Import copr-build-chain module from scripts directory.""" + import importlib.util + spec = importlib.util.spec_from_file_location( + "copr_build_chain", + os.path.join(os.path.dirname(__file__), "..", "scripts", "copr-build-chain.py"), + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _create_spec(tmpdir: Path, filename: str, name: str = None) -> Path: + """Create a minimal .spec file returning the path.""" + if name is None: + name = Path(filename).stem + spec_path = tmpdir / filename + spec_path.write_text( + f"Name: {name}\n" + f"Version: 1.0\n" + f"Release: 1%{{?dist}}\n" + f"License: MIT\n" + f"Summary: Test package\n" + f"%description\nTest description.\n" + ) + return spec_path + + +class TestGetRpmName: + """Test extraction of RPM Name from spec files.""" + + def test_basic_spec(self): + """Standard spec file with Name field.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + spec = _create_spec(Path(tmpdir), "glib2.spec", name="glib2") + result = mod.get_rpm_name(str(spec)) + assert result == "glib2", f"Expected 'glib2', got {result!r}" + + def test_spec_with_dashes(self): + """Spec with name containing dashes.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + spec = _create_spec(Path(tmpdir), "gnome-shell.spec", name="gnome-shell") + result = mod.get_rpm_name(str(spec)) + assert result == "gnome-shell", f"Expected 'gnome-shell', got {result!r}" + + def test_spec_with_plus_in_name(self): + """Spec with name containing plus sign.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + spec = _create_spec(Path(tmpdir), "gtk4.spec", name="gtk4+extra") + result = mod.get_rpm_name(str(spec)) + assert result == "gtk4+extra", f"Expected 'gtk4+extra', got {result!r}" + + def test_missing_spec_file(self): + """Missing spec file raises FileNotFoundError (no graceful handling yet).""" + mod = _import_module() + import pytest + with pytest.raises(FileNotFoundError): + mod.get_rpm_name("/nonexistent/path/pkg.spec") + + def test_spec_with_extra_whitespace(self): + """Spec Name field with extra whitespace is stripped.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + spec = Path(tmpdir) / "mutter.spec" + spec.write_text("Name:\tmutter\nVersion: 42.0\n") + result = mod.get_rpm_name(str(spec)) + assert result == "mutter", f"Expected 'mutter', got {result!r}" + + +class TestResolvePkg: + """Test package resolution from path/spec_override/copr_name.""" + + def test_copr_name_override(self): + """copr_name_override bypasses spec lookup entirely.""" + mod = _import_module() + result = mod.resolve_pkg( + pkg_path="/some/path", + spec_override=None, + copr_name_override="gnome-shell", + ) + assert result == ("gnome-shell", None, "gnome-shell"), ( + f"Unexpected result: {result}" + ) + + def test_spec_override_bootstrap(self): + """spec_override derives COPR name from spec filename stem.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + _create_spec(d, "glib2-bootstrap.spec", name="glib2") + result = mod.resolve_pkg(str(d), spec_override="glib2-bootstrap.spec") + # copr_pkg_name = stem of spec_override = "glib2-bootstrap" + assert result is not None, "Expected a result, got None" + copr_name, spec_file, rpm_name = result + assert copr_name == "glib2-bootstrap", ( + f"Expected COPR name 'glib2-bootstrap', got {copr_name!r}" + ) + assert spec_file is not None and spec_file.endswith("glib2-bootstrap.spec") + assert rpm_name == "glib2", ( + f"Expected RPM name 'glib2', got {rpm_name!r}" + ) + + def test_no_override_picks_first_spec(self): + """No spec_override picks the first non-bootstrap, non-rawhide spec.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + # Create multiple specs — should pick the non-bootstrap one + _create_spec(d, "glib2-bootstrap.spec", name="glib2") + _create_spec(d, "glib2.spec", name="glib2") + result = mod.resolve_pkg(str(d), spec_override=None) + assert result is not None, "Expected a result, got None" + copr_name, spec_file, rpm_name = result + assert copr_name == "glib2", ( + f"Expected COPR name 'glib2', got {copr_name!r}" + ) + assert "bootstrap" not in spec_file, ( + f"Should not pick bootstrap spec, got {spec_file}" + ) + + def test_only_bootstrap_spec_available(self): + """When only bootstrap specs exist, picks the first one.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + _create_spec(d, "glib2-bootstrap.spec", name="glib2") + result = mod.resolve_pkg(str(d), spec_override=None) + assert result is not None, "Expected a result, got None" + copr_name, _, rpm_name = result + assert copr_name == "glib2", f"Expected 'glib2', got {copr_name!r}" + assert rpm_name == "glib2", f"Expected 'glib2', got {rpm_name!r}" + + def test_no_specs_at_all(self): + """Directory with no .spec files returns (None, None, None).""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + result = mod.resolve_pkg(str(d), spec_override=None) + assert result == (None, None, None), ( + f"Expected (None, None, None), got {result}" + ) + + def test_spec_override_file_not_found(self): + """Spec override referencing a missing file returns (None, None, None).""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + result = mod.resolve_pkg(str(d), spec_override="missing.spec") + assert result == (None, None, None), ( + f"Expected (None, None, None) for missing spec, got {result}" + ) + + def test_different_rpm_name_from_spec(self): + """When spec filename and RPM Name differ, COPR name follows the rule.""" + mod = _import_module() + with tempfile.TemporaryDirectory() as tmpdir: + d = Path(tmpdir) + _create_spec(d, "weird-filename.spec", name="actual-rpm-name") + result = mod.resolve_pkg(str(d), spec_override=None) + assert result is not None, "Expected a result, got None" + copr_name, _, rpm_name = result + assert rpm_name == "actual-rpm-name", ( + f"Expected rpm_name 'actual-rpm-name', got {rpm_name!r}" + ) + # Without spec_override, copr_name = rpm_name + assert copr_name == "actual-rpm-name", ( + f"Expected copr_name 'actual-rpm-name', got {copr_name!r}" + )