"""Unit tests for devx.tools.build_image.""" from __future__ import annotations import json from pathlib import Path from unittest.mock import MagicMock, patch import pytest from click import ClickException from click.testing import CliRunner import devx.tools.build_image as build_image from devx.tools.build_image import ( ImageSpec, build_full_tag, load_manifest, push_image, registry_login, ) from devx.tools.build_image import ( build_image as do_build, ) from devx.tools.clean_images import select_for_deletion, sort_versions_by_date class TestImageSpec: def test_from_dict_minimal(self) -> None: spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"}) assert spec.name == "ci-base" assert spec.dockerfile == "docker/ci-base/Dockerfile" assert spec.context == "." assert spec.tags == ["latest"] def test_from_dict_full(self) -> None: spec = ImageSpec.from_dict( { "name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "context": ".", "tags": ["latest", "0.19.3"], } ) assert spec.name == "ci-quality" assert spec.dockerfile == "docker/ci-quality/Dockerfile" assert spec.context == "." assert spec.tags == ["latest", "0.19.3"] def test_from_dict_missing_name(self) -> None: with pytest.raises(ValueError, match="missing 'name'"): ImageSpec.from_dict({"dockerfile": "Dockerfile"}) def test_from_dict_missing_dockerfile(self) -> None: with pytest.raises(ValueError, match="missing 'dockerfile'"): ImageSpec.from_dict({"name": "ci-base"}) def test_from_dict_tags_not_list(self) -> None: with pytest.raises(ValueError, match="tags.*must be a list"): ImageSpec.from_dict( { "name": "ci-base", "dockerfile": "Dockerfile", "tags": "latest", } ) def test_from_dict_empty_tags_defaults_to_latest(self) -> None: spec = ImageSpec.from_dict( { "name": "ci-base", "dockerfile": "Dockerfile", "tags": [], } ) assert spec.tags == ["latest"] class TestBuildFullTag: def test_no_registry(self) -> None: assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest" def test_with_registry(self) -> None: assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0" def test_with_registry_and_path(self) -> None: assert ( build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest" ) class TestLoadManifest: def test_load_valid_manifest(self, tmp_path: Path) -> None: manifest = tmp_path / "images.json" manifest.write_text( json.dumps( [ {"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"}, {"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]}, ] ) ) specs = load_manifest(manifest) assert len(specs) == 2 assert specs[0].name == "ci-base" assert specs[1].tags == ["latest", "1.0"] def test_load_missing_file(self, tmp_path: Path) -> None: with pytest.raises(ClickException, match="not found"): load_manifest(tmp_path / "nonexistent.json") def test_load_not_a_list(self, tmp_path: Path) -> None: manifest = tmp_path / "images.json" manifest.write_text(json.dumps({"name": "ci-base"})) with pytest.raises(ClickException, match="must be a JSON list"): load_manifest(manifest) class TestRegistryLogin: def test_success(self) -> None: mock_result = MagicMock(returncode=0, stderr="", stdout="") with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: assert registry_login("git.example.com", "user", "token") is True assert mock_run.call_args.args[0] == [ "docker", "login", "git.example.com", "-u", "user", "--password-stdin", ] assert mock_run.call_args.kwargs["input"] == "token" def test_failure(self) -> None: mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="") with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): assert registry_login("git.example.com", "user", "bad") is False def test_dry_run(self) -> None: with patch("devx.tools.build_image.subprocess.run") as mock_run: assert registry_login("git.example.com", "user", "token", dry_run=True) is True mock_run.assert_not_called() class TestBuildImage: def test_success(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"]) mock_result = MagicMock(returncode=0) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): assert do_build(spec) is True def test_dockerfile_not_found(self) -> None: spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile") assert do_build(spec) is False def test_build_failure(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile)) mock_result = MagicMock(returncode=1) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): assert do_build(spec) is False def test_dry_run(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"]) with patch("devx.tools.build_image.subprocess.run") as mock_run: assert do_build(spec, dry_run=True) is True mock_run.assert_not_called() def test_with_registry(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"]) mock_result = MagicMock(returncode=0) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: assert do_build(spec, registry="git.example.com") is True cmd = mock_run.call_args.args[0] assert "-t" in cmd idx = cmd.index("-t") assert cmd[idx + 1] == "git.example.com/ci-base:latest" def test_pull_flag(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile)) mock_result = MagicMock(returncode=0) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: assert do_build(spec, pull=True) is True cmd = mock_run.call_args.args[0] assert "--pull" in cmd class TestPushImage: def test_success(self) -> None: spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) mock_result = MagicMock(returncode=0, stderr="", stdout="") with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run: assert push_image(spec, "git.example.com") is True assert mock_run.call_count == 2 def test_partial_failure(self) -> None: spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"]) results = [ MagicMock(returncode=0, stderr="", stdout=""), MagicMock(returncode=1, stderr="push failed", stdout=""), ] with patch("devx.tools.build_image.subprocess.run", side_effect=results): assert push_image(spec, "git.example.com") is False def test_dry_run(self) -> None: spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"]) with patch("devx.tools.build_image.subprocess.run") as mock_run: assert push_image(spec, "git.example.com", dry_run=True) is True mock_run.assert_not_called() class TestSortVersions: def test_sort_by_created_at_desc(self) -> None: versions = [ {"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"}, {"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"}, {"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"}, ] result = sort_versions_by_date(versions) assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"] def test_sort_fallback_to_version(self) -> None: versions = [ {"version": "0.1.0"}, {"version": "0.3.0"}, {"version": "0.2.0"}, ] result = sort_versions_by_date(versions) assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"] class TestSelectForDeletion: def test_keep_2(self) -> None: versions = [ {"version": "0.1.0", "created_at": "2025-01-01"}, {"version": "0.2.0", "created_at": "2025-02-01"}, {"version": "0.3.0", "created_at": "2025-03-01"}, {"version": "0.4.0", "created_at": "2025-04-01"}, ] to_delete = select_for_deletion(versions, keep=2) assert len(to_delete) == 2 assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"} def test_preserve_latest_tag(self) -> None: versions = [ {"version": "latest", "created_at": "2025-01-01"}, {"version": "0.2.0", "created_at": "2025-02-01"}, {"version": "0.3.0", "created_at": "2025-03-01"}, {"version": "0.4.0", "created_at": "2025-04-01"}, ] to_delete = select_for_deletion(versions, keep=2) deleted_versions = {v["version"] for v in to_delete} assert "latest" not in deleted_versions # latest is oldest by date but still preserved assert "0.2.0" in deleted_versions def test_keep_all(self) -> None: versions = [ {"version": "0.1.0", "created_at": "2025-01-01"}, {"version": "0.2.0", "created_at": "2025-02-01"}, ] to_delete = select_for_deletion(versions, keep=2) assert len(to_delete) == 0 def test_keep_more_than_available(self) -> None: versions = [ {"version": "0.1.0", "created_at": "2025-01-01"}, ] to_delete = select_for_deletion(versions, keep=5) assert len(to_delete) == 0 class TestCleanImagesAPI: """Tests for the clean_images module's API functions.""" def test_list_package_versions(self) -> None: from devx.tools.clean_images import list_package_versions mock_resp = MagicMock() mock_resp.json.return_value = [{"version": "0.1.0"}] mock_resp.raise_for_status = MagicMock() with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get: versions = list_package_versions( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "token", ) assert versions == [{"version": "0.1.0"}] assert "page=1" in mock_get.call_args.args[0] def test_list_package_versions_pagination(self) -> None: from devx.tools.clean_images import list_package_versions # First page: 50 items, second page: 3 items, third page: empty page1 = [{"version": f"0.{i}.0"} for i in range(50)] page2 = [{"version": f"1.{i}.0"} for i in range(3)] responses = [ MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()), MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()), MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()), ] with patch("devx.tools.clean_images.requests.get", side_effect=responses): versions = list_package_versions( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "token", ) assert len(versions) == 53 def test_delete_package_version_success(self) -> None: from devx.tools.clean_images import delete_package_version mock_resp = MagicMock(status_code=204) with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp) as mock_del: assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", ) is True ) # Verify URL includes container type url = mock_del.call_args.args[0] assert "/container/" in url def test_delete_package_version_404_treated_as_success(self) -> None: from devx.tools.clean_images import delete_package_version mock_resp = MagicMock(status_code=404) with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", ) is True ) def test_delete_package_version_failure(self) -> None: from devx.tools.clean_images import delete_package_version mock_resp = MagicMock(status_code=403) with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp): assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", ) is False ) def test_delete_package_version_retries_on_5xx(self) -> None: from devx.tools.clean_images import delete_package_version responses = [ MagicMock(status_code=500), MagicMock(status_code=502), MagicMock(status_code=204), ] with patch("devx.tools.clean_images.requests.delete", side_effect=responses): with patch("devx.tools.clean_images.time.sleep"): assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", max_retries=3, ) is True ) def test_delete_package_version_retries_on_exception(self) -> None: import requests as req from devx.tools.clean_images import delete_package_version responses = [ req.ConnectionError("network down"), MagicMock(status_code=204), ] with patch("devx.tools.clean_images.requests.delete", side_effect=responses): with patch("devx.tools.clean_images.time.sleep"): assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", max_retries=3, ) is True ) def test_delete_package_version_exhausts_retries_on_exception(self) -> None: import requests as req from devx.tools.clean_images import delete_package_version with patch( "devx.tools.clean_images.requests.delete", side_effect=req.ConnectionError("network down"), ): with patch("devx.tools.clean_images.time.sleep"): assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", max_retries=2, ) is False ) def test_delete_package_version_exhausts_retries_on_5xx(self) -> None: from devx.tools.clean_images import delete_package_version with patch( "devx.tools.clean_images.requests.delete", return_value=MagicMock(status_code=500), ): with patch("devx.tools.clean_images.time.sleep"): assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", max_retries=2, ) is False ) def test_delete_package_version_zero_retries(self) -> None: from devx.tools.clean_images import delete_package_version with patch("devx.tools.clean_images.requests.delete") as mock_del: assert ( delete_package_version( "https://git.example.com/api/v1", "oblachno-oss", "ci-base", "0.1.0", "token", max_retries=0, ) is False ) mock_del.assert_not_called() class TestCLIBuildImage: def test_single_image_build(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() mock_result = MagicMock(returncode=0) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"], ) assert result.exit_code == 0 def test_manifest_build(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() manifest = tmp_path / "images.json" manifest.write_text( json.dumps( [ {"name": "ci-base", "dockerfile": str(dockerfile)}, ] ) ) runner = CliRunner() mock_result = MagicMock(returncode=0) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): result = runner.invoke( build_image.main, ["--manifest", str(manifest)], ) assert result.exit_code == 0 def test_missing_dockerfile_and_manifest(self) -> None: runner = CliRunner() with patch("devx.tools.build_image.subprocess.run"): result = runner.invoke(build_image.main, []) assert result.exit_code != 0 assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower() def test_push_without_registry(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() with patch("devx.tools.build_image.subprocess.run"): result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"], ) assert result.exit_code != 0 assert "registry" in result.output.lower() def test_push_without_credentials(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() with patch("devx.tools.build_image.subprocess.run"), patch.dict("os.environ", {}, clear=True): result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"], ) assert result.exit_code != 0 assert "credential" in result.output.lower() or "token" in result.output.lower() def test_dry_run(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() with patch("devx.tools.build_image.subprocess.run") as mock_run: result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"], ) assert result.exit_code == 0 mock_run.assert_not_called() assert "dry-run" in result.output def test_build_failure_exits_with_error(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() mock_result = MagicMock(returncode=1) with patch("devx.tools.build_image.subprocess.run", return_value=mock_result): result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base"], ) assert result.exit_code != 0 def test_push_login_failure(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() login_result = MagicMock(returncode=1, stderr="auth failed", stdout="") with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}): with patch("devx.tools.build_image.subprocess.run", return_value=login_result): result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"], ) assert result.exit_code != 0 assert "login" in result.output.lower() def test_push_image_failure(self, tmp_path: Path) -> None: dockerfile = tmp_path / "Dockerfile" dockerfile.touch() runner = CliRunner() build_result = MagicMock(returncode=0) login_result = MagicMock(returncode=0, stderr="", stdout="") push_result = MagicMock(returncode=1, stderr="push failed", stdout="") with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}): with patch( "devx.tools.build_image.subprocess.run", side_effect=[login_result, build_result, push_result], ): result = runner.invoke( build_image.main, ["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"], ) assert result.exit_code != 0 class TestCLICleanImages: def test_dry_run(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() mock_resp = MagicMock() mock_resp.json.return_value = [ {"version": "0.1.0", "created_at": "2025-01-01"}, {"version": "0.2.0", "created_at": "2025-02-01"}, {"version": "0.3.0", "created_at": "2025-03-01"}, ] mock_resp.raise_for_status = MagicMock() with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): result = runner.invoke( clean_main, ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"], ) assert result.exit_code == 0 assert "dry-run" in result.output assert "0.1.0" in result.output def test_no_token(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() with patch.dict("os.environ", {}, clear=True): result = runner.invoke( clean_main, ["--owner", "oblachno-oss", "--name", "ci-base"], ) assert result.exit_code != 0 assert "token" in result.output.lower() def test_no_versions_found(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() mock_resp = MagicMock() mock_resp.json.return_value = [] mock_resp.raise_for_status = MagicMock() with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): result = runner.invoke( clean_main, ["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"], ) assert result.exit_code == 0 assert "No versions" in result.output def test_actual_delete(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() list_resp = MagicMock() list_resp.json.return_value = [ {"version": "0.1.0", "created_at": "2025-01-01"}, {"version": "0.2.0", "created_at": "2025-02-01"}, {"version": "0.3.0", "created_at": "2025-03-01"}, ] list_resp.raise_for_status = MagicMock() delete_resp = MagicMock(status_code=204) with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch("devx.tools.clean_images.requests.get", return_value=list_resp): with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): result = runner.invoke( clean_main, ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"], ) assert result.exit_code == 0 assert "Deleted" in result.output def test_list_request_exception(self) -> None: import requests as req from devx.tools.clean_images import main as clean_main runner = CliRunner() with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch( "devx.tools.clean_images.requests.get", side_effect=req.ConnectionError("network down"), ): result = runner.invoke( clean_main, ["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"], ) assert result.exit_code != 0 assert "Failed to list" in result.output def test_delete_failure_in_cli(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() list_resp = MagicMock() list_resp.json.return_value = [ {"version": "0.1.0", "created_at": "2025-01-01"}, {"version": "0.2.0", "created_at": "2025-02-01"}, {"version": "0.3.0", "created_at": "2025-03-01"}, ] list_resp.raise_for_status = MagicMock() delete_resp = MagicMock(status_code=403) with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch("devx.tools.clean_images.requests.get", return_value=list_resp): with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp): with patch("devx.tools.clean_images.time.sleep"): result = runner.invoke( clean_main, ["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"], ) assert result.exit_code != 0 assert "FAILED" in result.output @patch("devx.tools.clean_images.REPO_OWNER", "oblachno-oss") def test_owner_from_config(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() mock_resp = MagicMock() mock_resp.json.return_value = [] mock_resp.raise_for_status = MagicMock() with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): with patch("devx.tools.clean_images.requests.get", return_value=mock_resp): result = runner.invoke( clean_main, ["--name", "ci-base", "--dry-run"], ) assert result.exit_code == 0 assert "oblachno-oss/ci-base" in result.output @patch("devx.tools.clean_images.REPO_OWNER", "") def test_no_owner_raises(self) -> None: from devx.tools.clean_images import main as clean_main runner = CliRunner() with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}): result = runner.invoke( clean_main, ["--name", "ci-base"], ) assert result.exit_code != 0 assert "owner" in result.output.lower()