"""Unit tests for devx.ci.publish.""" from pathlib import Path from unittest.mock import MagicMock, patch import click import pytest from click.testing import CliRunner from devx.ci.publish import ( _default_gitea_registry_url, build_package, generate_release_notes, main, publish_to_gitea_registry, publish_to_pypi, ) from devx.gitea_cli import TeaCLIError class TestGenerateReleaseNotes: @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.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("devx.ci.publish.subprocess.run") @patch("devx.ci.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("devx.ci.publish.subprocess.run") @patch("devx.ci.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("devx.ci.publish.subprocess.run") @patch("devx.ci.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("devx.ci.publish.subprocess.run", side_effect=FileNotFoundError) @patch("devx.ci.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("devx.ci.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("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.Path.exists", return_value=False) def test_success(self, mock_exists: MagicMock, 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("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.Path.exists", return_value=False) def test_failure_raises(self, mock_exists: MagicMock, 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) @patch("devx.ci.publish.subprocess.run") @patch("devx.ci.publish.shutil.rmtree") @patch("devx.ci.publish.Path.exists", return_value=True) def test_cleans_dist_before_build( self, mock_exists: MagicMock, mock_rmtree: MagicMock, mock_run: MagicMock ) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") build_package() mock_rmtree.assert_called_once_with(Path("dist")) class TestPublishToPypi: @patch("devx.ci.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("devx.ci.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 TestPublishToGiteaRegistry: @patch("devx.ci.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") args, _ = mock_run.call_args assert "twine" in args[0] assert "--repository-url" in args[0] assert "https://git.example.com/api/packages/owner/pypi" in args[0] assert "gitea-tok" in args[0] @patch("devx.ci.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="registry upload failed") with pytest.raises(click.ClickException) as exc: publish_to_gitea_registry("https://git.example.com/api/packages/owner/pypi", "gitea-tok") assert "Gitea PyPI registry" in str(exc.value) class TestDefaultGiteaRegistryUrl: @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1") def test_derives_from_api_url(self) -> None: url = _default_gitea_registry_url() assert url == "https://git.example.com/api/packages/myorg/pypi" @patch.dict("os.environ", {"DEVX_REPO_OWNER": "myorg"}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api") def test_derives_from_api_url_no_v1(self) -> None: url = _default_gitea_registry_url() assert url == "https://git.example.com/api/packages/myorg/pypi" @patch.dict("os.environ", {}, clear=True) @patch("devx.ci.publish.GITEA_API_URL", "https://git.example.com/api/v1") def test_default_owner(self) -> None: url = _default_gitea_registry_url() assert "oblachno-oss" in url class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_full_flow_with_pypi( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: mock_tea = MagicMock() mock_tea_cls.return_value = mock_tea 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_tea.create_release.assert_called_once_with( "owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" ) @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.build_package") def test_without_pypi_uses_gitea_registry( self, mock_build: MagicMock, mock_gitea_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """When no PYPI_TOKEN, publishes to Gitea PyPI registry.""" mock_tea = MagicMock() mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 mock_build.assert_called_once() mock_gitea_publish.assert_called_once() mock_tea.create_release.assert_called_once() @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.build_package") def test_with_registry_url_flag( self, mock_build: MagicMock, mock_gitea_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """--registry-url flag publishes to the specified Gitea registry.""" mock_tea = MagicMock() mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke( main, ["v1.0.0", "owner/repo", "--registry-url", "https://custom.registry.com/pypi"], ) assert result.exit_code == 0 mock_gitea_publish.assert_called_once_with("https://custom.registry.com/pypi", "gitea-tok") @patch.dict( "os.environ", {"REPO_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"}, clear=True, ) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.build_package") def test_registry_url_from_env( self, mock_build: MagicMock, mock_gitea_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """DEVX_PYPI_REGISTRY_URL env var sets the registry URL.""" mock_tea = MagicMock() mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 0 mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok") @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.build_package") @patch("devx.ci.publish._default_gitea_registry_url", return_value="") def test_no_pypi_no_registry_skips_publish( self, mock_default_url: MagicMock, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock, ) -> None: """When no PYPI_TOKEN and no registry URL, skips publish and creates release only.""" mock_tea = MagicMock() mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo", "--registry-url", ""]) assert result.exit_code == 0 assert "PYPI_TOKEN not set" in result.output mock_tea.create_release.assert_called_once() @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("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_build_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_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("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_publish_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_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("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.build_package") def test_release_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: mock_tea = MagicMock() mock_tea.create_release.side_effect = TeaCLIError("server error") mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo"]) assert result.exit_code == 1 assert "Release creation failed" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.build_package") def test_skip_build_skips_build_and_publish( self, mock_build: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock ) -> None: """--skip-build skips build_package and PyPI publish, only creates Gitea release.""" mock_tea = MagicMock() mock_tea_cls.return_value = mock_tea runner = CliRunner() result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"]) assert result.exit_code == 0 assert "skip" in result.output.lower() mock_build.assert_not_called() mock_tea.create_release.assert_called_once()