"""Unit tests for scripts/publish.py.""" import http from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from scripts.publish import ( build_package, generate_release_notes, main, publish_to_pypi, ) class TestGenerateReleaseNotes: @patch("scripts.publish.subprocess.run") @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x") result = generate_release_notes("v1.0.0") assert "## v1.0.0" in result assert "feat: x" in result @patch("scripts.publish.subprocess.run") @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n") result = generate_release_notes("v1.0.0") assert result == "changelog" @patch("scripts.publish.subprocess.run") @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="") result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result @patch("scripts.publish.subprocess.run") @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout=" ") result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result @patch("scripts.publish.subprocess.run", side_effect=FileNotFoundError) @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None: result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result @patch("scripts.publish.shutil.which", return_value=None) def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None: result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result class TestBuildPackage: @patch("scripts.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") build_package() args, _ = mock_run.call_args assert args[0][1] == "-m" assert args[0][2] == "build" @patch("scripts.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="build error") with pytest.raises(click.ClickException) as exc: build_package() assert "build" in str(exc.value) class TestPublishToPypi: @patch("scripts.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") publish_to_pypi("pypi-tok") args, _ = mock_run.call_args assert "twine" in args[0] assert "pypi-tok" in args[0] @patch("scripts.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="upload failed") with pytest.raises(click.ClickException) as exc: publish_to_pypi("pypi-tok") assert "PyPI" in str(exc.value) class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_full_flow_with_pypi( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock, ) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 assert "Gitea release v1.0.0 created" in result.output mock_build.assert_called_once() mock_publish.assert_called_once_with("pypi-tok") mock_client_cls.return_value.create_release.assert_called_once() # Verify release body uses git-cliff notes call_args = mock_client_cls.return_value.create_release.call_args assert call_args.kwargs["body"] == "Release notes" @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.build_package") def test_without_pypi( self, mock_build: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock, ) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 mock_build.assert_called_once() mock_client_cls.return_value.create_release.assert_called_once() assert "PYPI_TOKEN not set" in result.output @patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) def test_missing_repo_token_exits(self) -> None: runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_build_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_build.side_effect = click.ClickException("build failed") runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "build" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_publish_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_publish.side_effect = click.ClickException("publish failed") runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "publish" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_release_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_client = MagicMock() from gitea_runner_manager.exceptions import APIError mock_client.create_release.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "HTTP" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("scripts.publish.generate_release_notes", return_value="Release notes") @patch("scripts.publish.GiteaClient") @patch("scripts.publish.publish_to_pypi") @patch("scripts.publish.build_package") def test_release_json_parse_failure( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_client = MagicMock() from gitea_runner_manager.exceptions import APIError mock_client.create_release.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway") mock_client_cls.return_value = mock_client runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert str(http.HTTPStatus.BAD_GATEWAY) in result.output