from __future__ import annotations from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest from click import ClickException from click.testing import CliRunner import devx.ci.push_badges as push_badges class TestFetchLatestMaster: def test_fetch_and_reset(self) -> None: with patch("subprocess.run") as mock_run: push_badges.fetch_latest_master("master") # Should call git fetch and git reset --hard calls = [str(c.args[0]) for c in mock_run.call_args_list] assert any("fetch" in c for c in calls) assert any("reset" in c for c in calls) def test_custom_branch(self) -> None: with patch("subprocess.run") as mock_run: push_badges.fetch_latest_master("develop") calls = [list(c.args[0]) for c in mock_run.call_args_list] # fetch call should include the branch name fetch_call = [c for c in calls if "fetch" in c][0] assert "develop" in fetch_call # reset call should include origin/develop reset_call = [c for c in calls if "reset" in c][0] assert "origin/develop" in reset_call def test_fetch_failure_raises(self) -> None: import subprocess with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")): with pytest.raises(subprocess.CalledProcessError): push_badges.fetch_latest_master() class TestGenerateBadges: def test_success(self, tmp_path: Path) -> None: output_dir = tmp_path / ".badges" output_dir.mkdir() (output_dir / "badge1.svg").touch() with patch("subprocess.run") as mock_run: push_badges.generate_badges(str(output_dir)) mock_run.assert_called_once() def test_no_badges_generated(self, tmp_path: Path) -> None: output_dir = tmp_path / ".badges" output_dir.mkdir() with patch("subprocess.run"): with pytest.raises(ClickException, match="No badge SVG files generated"): push_badges.generate_badges(str(output_dir)) class TestPushToBadgesBranch: def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() svg = badges_dir / "badge1.svg" svg.write_text("") sha_result = MagicMock() sha_result.stdout = "abc123\n" diff_result = MagicMock() diff_result.stdout = "coverage.svg\n" default_result = MagicMock() with patch( "subprocess.run", side_effect=[default_result] * 6 + [diff_result] + [default_result, default_result, sha_result], ) as mock_run: sha = push_badges.push_to_badges_branch(str(badges_dir)) # Should have called git config x2, checkout, rm, add, commit, push, rev-parse assert mock_run.call_count >= 8 # Verify the SVG was copied to cwd assert (tmp_path / "badge1.svg").exists() assert sha == "abc123" class TestUpdateBadgeUrls: def test_replaces_branch_url(self) -> None: content = "[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]" result = push_badges.update_badge_urls(content, "abc123def456") assert "raw/commit/abc123def456/tests.svg" in result assert "raw/branch/badges" not in result def test_replaces_commit_url(self) -> None: """Old commit SHA URLs should be replaced with the new one.""" old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars content = f"[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/commit/{old_sha}/tests.svg)]" result = push_badges.update_badge_urls(content, new_sha) assert f"raw/commit/{new_sha}/tests.svg" in result assert old_sha not in result def test_no_badge_urls(self) -> None: content = "# No badges here\nJust text." result = push_badges.update_badge_urls(content, "abc123") assert result == content def test_multiple_badges(self) -> None: content = ( "[![Coverage](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/coverage.svg)]\n" "[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]\n" "[![Version](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/version.svg)]" ) result = push_badges.update_badge_urls(content, "abc123def456") assert result.count("raw/commit/abc123def456/") == 3 assert "raw/branch/badges" not in result def test_preserves_non_badge_urls(self) -> None: content = "[![CI](https://git.oblachno.oblachno.fyi/my-org/my-repo/actions/workflows/ci.yml/badge.svg)]" result = push_badges.update_badge_urls(content, "abc123") assert result == content class TestUpdateReadmeWithBadgeSha: def test_updates_readme(self, tmp_path: Path) -> None: readme = tmp_path / "README.md" readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]") with patch("subprocess.run"): push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path) content = readme.read_text() assert "raw/commit/abc123def456/tests.svg" in content def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None: readme = tmp_path / "README.md" readme.write_text("# No badges here") with patch("subprocess.run") as mock_run: push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path) # Should checkout master but not commit/push calls = [list(c.args[0]) for c in mock_run.call_args_list] assert not any("commit" in c for c in calls) assert not any("push" in c for c in calls) def test_missing_readme_skips(self, tmp_path: Path) -> None: with patch("subprocess.run"): push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path) # Should not raise def test_version_verification_stale(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: readme = tmp_path / "README.md" readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]") monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir(exist_ok=True) (badges_dir / "version.svg").write_text("version: v0.27.0") with patch("subprocess.run"): with patch("devx.tools.generate_badges.detect_package_name", return_value="devx"): with patch("devx.tools.generate_badges.read_version", return_value="0.33.4"): push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path) def test_version_verification_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: readme = tmp_path / "README.md" readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]") monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir(exist_ok=True) (badges_dir / "version.svg").write_text("version: v0.33.4") with patch("subprocess.run"): with patch("devx.tools.generate_badges.detect_package_name", return_value="devx"): with patch("devx.tools.generate_badges.read_version", return_value="0.33.4"): push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path) def test_version_verification_no_badges_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: readme = tmp_path / "README.md" readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]") monkeypatch.chdir(tmp_path) # No .badges/version.svg exists — should skip verification gracefully with patch("subprocess.run"): with patch("devx.tools.generate_badges.detect_package_name", return_value="devx"): with patch("devx.tools.generate_badges.read_version", return_value="0.33.4"): push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path) class TestMain: def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() (badges_dir / "badge1.svg").touch() runner = CliRunner() with patch("subprocess.run"): result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"]) assert result.exit_code == 0 def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() runner = CliRunner() with patch("subprocess.run"): result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"]) assert result.exit_code != 0 def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() (badges_dir / "badge1.svg").touch() runner = CliRunner() with patch("subprocess.run") as mock_run: result = runner.invoke( push_badges.main, ["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"], ) assert result.exit_code == 0 # Verify fetch was called with the custom branch calls = [list(c.args[0]) for c in mock_run.call_args_list] fetch_call = [c for c in calls if "fetch" in c][0] assert "develop" in fetch_call def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(tmp_path) import subprocess runner = CliRunner() with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")): result = runner.invoke(push_badges.main, ["--no-readme-update"]) assert result.exit_code != 0 def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without --no-readme-update, update_readme_with_badge_sha is called.""" monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() (badges_dir / "badge1.svg").touch() runner = CliRunner() with ( patch("subprocess.run"), patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update, ): result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)]) assert result.exit_code == 0 mock_update.assert_called_once() def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """With --no-readme-update, update_readme_with_badge_sha is not called.""" monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() (badges_dir / "badge1.svg").touch() runner = CliRunner() with ( patch("subprocess.run"), patch("devx.ci.push_badges.update_readme_with_badge_sha") as mock_update, ): result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"]) assert result.exit_code == 0 mock_update.assert_not_called() def test_retries_success_on_second_attempt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """With --retries 3, first attempt fails but second succeeds.""" monkeypatch.chdir(tmp_path) badges_dir = tmp_path / ".badges" badges_dir.mkdir() (badges_dir / "badge1.svg").touch() import subprocess call_count = [0] def side_effect(*args: Any, **kwargs: Any) -> Any: call_count[0] += 1 # First call (git fetch) fails, rest succeed if call_count[0] == 1: raise subprocess.CalledProcessError(1, "git fetch") return MagicMock(returncode=0, stdout="", stderr="") runner = CliRunner() with ( patch("subprocess.run", side_effect=side_effect), patch("devx.ci.push_badges.update_readme_with_badge_sha"), patch("time.sleep"), ): result = runner.invoke( push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update", "--retries", "3"], ) assert result.exit_code == 0 def test_retries_exhausted(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """With --retries 2, all attempts fail and exit code is non-zero.""" monkeypatch.chdir(tmp_path) import subprocess runner = CliRunner() with ( patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")), patch("time.sleep"), ): result = runner.invoke( push_badges.main, ["--no-readme-update", "--retries", "2"], ) assert result.exit_code != 0 assert "failed after 2" in result.output def test_default_retries_is_one(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Without --retries, only one attempt is made (no retry on failure).""" monkeypatch.chdir(tmp_path) import subprocess runner = CliRunner() with ( patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")), patch("time.sleep") as mock_sleep, ): result = runner.invoke(push_badges.main, ["--no-readme-update"]) assert result.exit_code != 0 mock_sleep.assert_not_called() class TestRepoRoot: def test_uses_github_workspace(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITHUB_WORKSPACE", str(tmp_path)) assert push_badges._repo_root() == tmp_path def test_falls_back_to_cwd(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("GITHUB_WORKSPACE", raising=False) monkeypatch.chdir(tmp_path) assert push_badges._repo_root() == tmp_path def test_falls_back_when_workspace_invalid(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITHUB_WORKSPACE", "/nonexistent") monkeypatch.chdir(tmp_path) assert push_badges._repo_root() == tmp_path