diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a07dbc..ccc9679 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,54 +3,20 @@ name: CI on: push: branches: [main] - tags: ["v*"] pull_request: branches: [main] -jobs: - test: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - - steps: - - uses: actions/checkout@v6 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} - - - name: Install dependencies - run: uv sync --all-extras - - - name: Lint with ruff - run: | - uv run ruff check src tests - uv run ruff format --check src tests +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true - - name: Run tests - run: uv run pytest +permissions: + contents: read - publish: - needs: test - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - environment: pypi - - permissions: - id-token: write - - steps: - - uses: actions/checkout@v6 - - - name: Install uv - uses: astral-sh/setup-uv@v7 - - - name: Build package - run: uv build - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 +jobs: + test: + uses: ./.github/workflows/test.yml + with: + coverage: true + secrets: + codecov_token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b782783 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Release + +on: + push: + tags: + - "v*" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + test: + uses: ./.github/workflows/test.yml + + build: + needs: test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Verify version consistency + run: | + uv run python - <<'PY' + import os + import tomllib + + with open("pyproject.toml", "rb") as f: + pyproject_version = tomllib.load(f)["project"]["version"] + + with open("uv.lock", "rb") as f: + lock = tomllib.load(f) + lock_version = next( + package["version"] + for package in lock["package"] + if package["name"] == "ytstudio-cli" + ) + + tag = os.environ["GITHUB_REF"] + expected_tag = f"refs/tags/v{pyproject_version}" + if lock_version != pyproject_version: + raise SystemExit( + f"uv.lock version {lock_version} does not match " + f"pyproject.toml version {pyproject_version}" + ) + if tag != expected_tag: + raise SystemExit(f"tag {tag} does not match package version {expected_tag}") + PY + + - name: Build package + run: uv build + + - name: Smoke test wheel + run: | + uv venv /tmp/ytstudio-smoke + uv pip install --python /tmp/ytstudio-smoke/bin/python dist/*.whl + /tmp/ytstudio-smoke/bin/ytstudio --version + /tmp/ytstudio-smoke/bin/yts --version + /tmp/ytstudio-smoke/bin/ytstudio --help + /tmp/ytstudio-smoke/bin/ytstudio videos --help + + - name: Upload dist artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish: + needs: build + runs-on: ubuntu-latest + environment: pypi + + permissions: + id-token: write + + steps: + - name: Download dist artifacts + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..185eedf --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,76 @@ +name: Test + +on: + workflow_call: + inputs: + coverage: + description: "Report and upload coverage (3.12 only)" + type: boolean + default: false + secrets: + codecov_token: + required: false + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --locked --group dev + + - name: Lint with ruff + run: | + uv run ruff check src tests scripts + uv run ruff format --check src tests scripts + + - name: Run tests + run: uv run pytest + + - name: Write coverage summary + if: inputs.coverage && matrix.python-version == '3.12' + run: | + { + echo "## Coverage" + echo + echo '```text' + uv run coverage report + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Generate HTML coverage report + if: inputs.coverage && matrix.python-version == '3.12' + run: uv run coverage html + + - name: Upload coverage artifacts + if: inputs.coverage && matrix.python-version == '3.12' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.xml + htmlcov/ + + - name: Upload coverage to Codecov + if: inputs.coverage && matrix.python-version == '3.12' + uses: codecov/codecov-action@v5 + with: + files: coverage.xml + fail_ci_if_error: false + token: ${{ secrets.codecov_token }} diff --git a/README.md b/README.md index 0232f97..d96cc5b 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@

CI + Coverage PyPI Python

diff --git a/tests/test_api.py b/tests/test_api.py index a063243..35f9883 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,6 +1,7 @@ from unittest.mock import MagicMock, patch import pytest +from google.auth.exceptions import RefreshError from googleapiclient.errors import HttpError from typer import Exit from typer.testing import CliRunner @@ -34,6 +35,12 @@ def test_quota_exceeded_exits(self): with pytest.raises(SystemExit): handle_api_error(error) + def test_forbidden_exits(self): + error = make_http_error(403, "forbidden") + + with pytest.raises(SystemExit): + handle_api_error(error) + def test_other_errors_reraise(self): error = make_http_error(404) @@ -48,6 +55,57 @@ def test_returns_result(self): assert api(request) == {"items": []} + def test_refresh_error_exits(self): + request = MagicMock() + request.execute.side_effect = RefreshError("revoked") + + with pytest.raises(SystemExit): + api(request) + + +class TestGetCredentials: + def test_returns_none_when_profile_has_no_credentials(self): + with patch("ytstudio.api.load_credentials", return_value=None): + assert api_module.get_credentials("missing") is None + + def test_refreshes_expired_credentials_and_saves_new_token(self): + creds_data = { + "token": "old-token", + "refresh_token": "refresh-token", + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": "client-id", + "client_secret": "client-secret", + "scopes": api_module.SCOPES, + } + credentials = MagicMock() + credentials.expired = True + credentials.refresh_token = "refresh-token" + credentials.token = "new-token" + + with ( + patch("ytstudio.api.load_credentials", return_value=creds_data), + patch("ytstudio.api.Credentials", return_value=credentials), + patch("ytstudio.api.Request", return_value="request"), + patch("ytstudio.api.save_credentials") as save_credentials, + ): + assert api_module.get_credentials("work") is credentials + + credentials.refresh.assert_called_once_with("request") + save_credentials.assert_called_once_with({**creds_data, "token": "new-token"}, "work") + + def test_refresh_error_exits(self): + credentials = MagicMock() + credentials.expired = True + credentials.refresh_token = "refresh-token" + credentials.refresh.side_effect = RefreshError("revoked") + + with ( + patch("ytstudio.api.load_credentials", return_value={"token": "old"}), + patch("ytstudio.api.Credentials", return_value=credentials), + pytest.raises(SystemExit), + ): + api_module.get_credentials("work") + class TestGetAuthenticatedService: def test_exits_when_no_credentials(self): @@ -66,6 +124,40 @@ def test_passes_profile_to_get_credentials(self): build.assert_called_once_with("youtube", "v3", credentials=credentials) +class TestStatus: + def test_get_status_reports_expired_credentials(self): + credentials = MagicMock() + credentials.valid = False + with ( + patch("ytstudio.api.load_credentials", return_value={"token": "old"}), + patch("ytstudio.api.get_credentials", return_value=credentials), + ): + api_module.get_status("work") + + def test_get_status_prints_channel_details(self): + credentials = MagicMock() + credentials.valid = True + service = MagicMock() + service.channels.return_value.list.return_value.execute.return_value = { + "items": [ + { + "snippet": {"title": "Channel"}, + "statistics": {"subscriberCount": "10", "videoCount": "2"}, + } + ] + } + with ( + patch("ytstudio.api.load_credentials", return_value={"token": "ok"}), + patch("ytstudio.api.get_credentials", return_value=credentials), + patch("ytstudio.api.build", return_value=service), + ): + api_module.get_status("work") + + service.channels.return_value.list.assert_called_once_with( + part="snippet,statistics", mine=True + ) + + class TestCommands: def test_login_requires_client_secrets(self): with patch("ytstudio.api.CLIENT_SECRETS_FILE") as mock_file: @@ -86,6 +178,79 @@ def test_status_not_authenticated(self): assert "Not authenticated" in result.stdout +class TestHelpers: + def test_create_flow_uses_client_secrets_and_scopes(self): + with patch("ytstudio.api.InstalledAppFlow.from_client_secrets_file") as factory: + assert api_module._create_flow() is factory.return_value + + factory.assert_called_once_with( + str(api_module.CLIENT_SECRETS_FILE), scopes=api_module.SCOPES + ) + + def test_save_credentials_serializes_oauth_fields(self): + credentials = MagicMock( + token="token", + refresh_token="refresh", + token_uri="token-uri", + client_id="client-id", + client_secret="client-secret", + scopes=["scope"], + ) + + with patch("ytstudio.api.save_credentials") as save_credentials: + api_module._save_credentials(credentials, "work") + + save_credentials.assert_called_once_with( + { + "token": "token", + "refresh_token": "refresh", + "token_uri": "token-uri", + "client_id": "client-id", + "client_secret": "client-secret", + "scopes": ["scope"], + }, + "work", + ) + + def test_fetch_channel_info_returns_channel_metadata(self): + service = MagicMock() + service.channels.return_value.list.return_value.execute.return_value = { + "items": [{"id": "UC123", "snippet": {"title": "Channel", "customUrl": "@c"}}] + } + + with patch("ytstudio.api.build", return_value=service): + assert api_module._fetch_channel_info(MagicMock()) == { + "id": "UC123", + "title": "Channel", + "custom_url": "@c", + } + + def test_fetch_channel_info_returns_none_when_empty_or_error(self): + service = MagicMock() + service.channels.return_value.list.return_value.execute.return_value = {"items": []} + with patch("ytstudio.api.build", return_value=service): + assert api_module._fetch_channel_info(MagicMock()) is None + + with patch("ytstudio.api.build", side_effect=RuntimeError("offline")): + assert api_module._fetch_channel_info(MagicMock()) is None + + def test_show_login_success_saves_profile_meta_when_channel_known(self): + info = {"id": "UC123", "title": "Channel", "custom_url": "@c"} + with ( + patch("ytstudio.api._fetch_channel_info", return_value=info), + patch("ytstudio.api.save_profile_meta") as save_profile_meta, + ): + api_module._show_login_success(MagicMock(), "work") + + save_profile_meta.assert_called_once_with("work", info) + + def test_logout_clears_credentials(self): + with patch("ytstudio.api.clear_credentials") as clear_credentials: + api_module.logout() + + clear_credentials.assert_called_once() + + class TestAuthenticate: def test_normal_login_uses_local_server(self): credentials = MagicMock() diff --git a/tests/test_comments.py b/tests/test_comments.py index 4c7d1d7..0adb3e3 100644 --- a/tests/test_comments.py +++ b/tests/test_comments.py @@ -43,6 +43,31 @@ def test_list_spam_comments(self, mock_auth): assert result.exit_code == 0 assert "Likely Spam" in result.stdout + def test_list_json_output(self, mock_auth): + result = runner.invoke(app, ["comments", "list", "-o", "json"]) + assert result.exit_code == 0 + assert '"id": "UgwComment123"' in result.stdout + assert '"author": "Test User"' in result.stdout + + def test_publish_comments(self, mock_auth): + result = runner.invoke(app, ["comments", "publish", "UgwComment123", "UgwComment456"]) + assert result.exit_code == 0 + assert "2 comment(s) published" in result.stdout + mock_auth.comments.return_value.setModerationStatus.assert_called_once_with( + id="UgwComment123,UgwComment456", + moderationStatus="published", + ) + + def test_reject_comments_with_ban(self, mock_auth): + result = runner.invoke(app, ["comments", "reject", "UgwComment123", "--ban"]) + assert result.exit_code == 0 + assert "1 comment(s) rejected" in result.stdout + mock_auth.comments.return_value.setModerationStatus.assert_called_once_with( + id="UgwComment123", + moderationStatus="rejected", + banAuthor=True, + ) + def test_list_comments_disabled(self, mock_auth): mock_auth.commentThreads.return_value.list.return_value.execute.side_effect = Exception( "Comments disabled" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..770a15c --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,45 @@ +from unittest.mock import patch + +from typer.testing import CliRunner + +from ytstudio.main import _show_update_notification, app + +runner = CliRunner() + + +class TestMain: + def test_version_option_prints_current_version(self): + with patch("ytstudio.main.get_current_version", return_value="1.2.3"): + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0 + assert "ytstudio v1.2.3" in result.stdout + + def test_registers_update_notification_once(self): + with ( + patch("ytstudio.main.migrate_legacy_credentials") as migrate, + patch("ytstudio.main.get_status"), + patch("ytstudio.main.atexit.register") as register, + patch.dict("ytstudio.main._update_state", {"registered": False}), + ): + result = runner.invoke(app, ["status"]) + + assert result.exit_code == 0 + migrate.assert_called_once() + register.assert_called_once() + + def test_show_update_notification_prints_when_available(self): + with ( + patch("ytstudio.main.is_update_available", return_value=(True, "2.0.0")), + patch("ytstudio.main.get_current_version", return_value="1.0.0"), + patch("ytstudio.main.console.print") as print_, + ): + _show_update_notification() + + message = print_.call_args.args[0] + assert "Update available: 1.0.0 → 2.0.0" in message + assert "uv tool upgrade ytstudio-cli" in message + + def test_show_update_notification_swallows_errors(self): + with patch("ytstudio.main.is_update_available", side_effect=RuntimeError("network")): + _show_update_notification() diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..3bb7021 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,84 @@ +import json +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +from ytstudio import version as version_module + + +def _use_temp_cache(monkeypatch, tmp_path): + cache = tmp_path / "version_check.json" + monkeypatch.setattr(version_module, "VERSION_CACHE_FILE", cache) + return cache + + +class TestVersionCheck: + def test_check_pypi_version_returns_latest(self): + response = MagicMock() + response.read.return_value = json.dumps({"info": {"version": "1.2.3"}}).encode() + response.__enter__.return_value = response + + with patch("ytstudio.version.urllib.request.urlopen", return_value=response) as urlopen: + assert version_module.check_pypi_version() == "1.2.3" + + urlopen.assert_called_once_with(version_module.PYPI_URL, timeout=5) + + def test_check_pypi_version_returns_none_on_error(self): + with patch("ytstudio.version.urllib.request.urlopen", side_effect=OSError("offline")): + assert version_module.check_pypi_version() is None + + def test_get_latest_version_uses_valid_cache(self, monkeypatch, tmp_path): + cache = _use_temp_cache(monkeypatch, tmp_path) + cache.write_text( + json.dumps( + { + "latest_version": "2.0.0", + "checked_at": datetime.now().isoformat(), + } + ) + ) + + with patch("ytstudio.version.check_pypi_version") as check_pypi: + assert version_module.get_latest_version() == "2.0.0" + + check_pypi.assert_not_called() + + def test_get_latest_version_refreshes_stale_cache(self, monkeypatch, tmp_path): + cache = _use_temp_cache(monkeypatch, tmp_path) + cache.write_text( + json.dumps( + { + "latest_version": "1.0.0", + "checked_at": (datetime.now() - timedelta(days=2)).isoformat(), + } + ) + ) + + with patch("ytstudio.version.check_pypi_version", return_value="2.0.0"): + assert version_module.get_latest_version() == "2.0.0" + + assert json.loads(cache.read_text())["latest_version"] == "2.0.0" + + def test_get_latest_version_ignores_invalid_cache(self, monkeypatch, tmp_path): + cache = _use_temp_cache(monkeypatch, tmp_path) + cache.write_text("not json") + + with patch("ytstudio.version.check_pypi_version", return_value="2.0.0"): + assert version_module.get_latest_version() == "2.0.0" + + @pytest.mark.parametrize( + ("latest", "current", "expected"), + [ + ("2.0.0", "1.0.0", (True, "2.0.0")), + ("1.0.0", "1.0.0", (False, "1.0.0")), + (None, "1.0.0", (False, None)), + ("not-a-version", "1.0.0", (False, None)), + ], + ) + def test_is_update_available(self, latest, current, expected): + with ( + patch("ytstudio.version.get_latest_version", return_value=latest), + patch("ytstudio.version.get_current_version", return_value=current), + ): + assert version_module.is_update_available() == expected