diff --git a/.github/workflows/coverage-upload.yml b/.github/workflows/coverage-upload.yml index d526122..232cb3e 100644 --- a/.github/workflows/coverage-upload.yml +++ b/.github/workflows/coverage-upload.yml @@ -2,7 +2,7 @@ name: Coverage upload on: workflow_run: # zizmor: ignore[dangerous-triggers] - workflows: [Test-backend] + workflows: [Test] types: [completed] permissions: {} diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test.yml similarity index 69% rename from .github/workflows/test-backend.yml rename to .github/workflows/test.yml index 492a5f7..671dbf0 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test-backend +name: Test on: push: @@ -31,11 +31,12 @@ jobs: src: - .github/workflows/test-backend.yml - backend/** + - cli/** - .python-version - pyproject.toml - uv.lock - test: + test-backend: needs: - changes if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/master' @@ -69,18 +70,62 @@ jobs: run: uv run --no-sync bash scripts/test-cov.sh working-directory: backend env: - COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }} - CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }} + COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-backend + CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-backend - name: Store coverage files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: coverage-${{ runner.os }}-py${{ matrix.python-version }} + name: coverage-${{ runner.os }}-py${{ matrix.python-version }}-backend path: backend/coverage include-hidden-files: true + test-cli: + needs: + - changes + if: needs.changes.outputs.src == 'true' || github.ref == 'refs/heads/master' + strategy: + matrix: + os: [ ubuntu-latest ] + python-version: [ "3.10", "3.13", "3.14" ] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python-version }} + - name: Setup uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + # Before upgrading uv version, make sure astral-sh/setup-uv knows its checksum. + # See: https://github.com/astral-sh/setup-uv/issues/851#issuecomment-4282017837 + version: "0.11.7" + enable-cache: true + cache-dependency-glob: | + pyproject.toml + cli/pyproject.toml + uv.lock + - name: Install Dependencies + run: uv sync --project cli + - name: Test + run: uv run --no-sync bash scripts/test-cov.sh + working-directory: cli + env: + COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }}-cli + CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }}-cli + - name: Store coverage files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-${{ runner.os }}-py${{ matrix.python-version }}-cli + path: cli/coverage + include-hidden-files: true + coverage-combine: needs: - - test + - test-backend + - test-cli runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -128,4 +173,4 @@ jobs: uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 with: jobs: ${{ toJSON(needs) }} - allowed-skips: coverage-combine,test + allowed-skips: coverage-combine,test-backend,test-cli diff --git a/.gitignore b/.gitignore index 1e73087..b4cbf96 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ wheels/ .venv .env + +# Coverage files +.coverage diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 035ec3f..d253603 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -38,3 +38,6 @@ covered = "covered.cli:app" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] build-backend = "uv_build" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/cli/scripts/test-cov-html.sh b/cli/scripts/test-cov-html.sh new file mode 100644 index 0000000..3397a57 --- /dev/null +++ b/cli/scripts/test-cov-html.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +bash scripts/test-cov.sh --cov-report=term-missing --cov-report=html ${@} diff --git a/cli/scripts/test-cov.sh b/cli/scripts/test-cov.sh new file mode 100644 index 0000000..1f96043 --- /dev/null +++ b/cli/scripts/test-cov.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +bash scripts/test.sh --cov --cov-context=test ${@} diff --git a/cli/scripts/test.sh b/cli/scripts/test.sh new file mode 100644 index 0000000..3bd7ae8 --- /dev/null +++ b/cli/scripts/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e +set -x + +pytest tests ${@} diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py new file mode 100644 index 0000000..64cf5e6 --- /dev/null +++ b/cli/tests/conftest.py @@ -0,0 +1,33 @@ +import os +from collections.abc import Iterator +from unittest.mock import AsyncMock, patch + +import pytest +import stamina +from typer import rich_utils + + +@pytest.fixture +def mock_main() -> Iterator[AsyncMock]: + with patch("covered.cli._main", AsyncMock(return_value=None)) as m: + yield m + + +@pytest.fixture(autouse=True) +def _isolate_covered_env(monkeypatch: pytest.MonkeyPatch) -> None: + """ + Clear `COVERED_*` env vars from the parent shell so tests aren't polluted. + """ + for var in [k for k in os.environ if k.startswith("COVERED_")]: + monkeypatch.delenv(var) # pragma: no cover + + +@pytest.fixture(autouse=True, scope="session") +def _set_stamina_testing(): + stamina.set_testing(True, attempts=10, cap=True) + + +@pytest.fixture(autouse=True, scope="session") +def setup_terminal() -> None: + rich_utils.MAX_WIDTH = 3000 + rich_utils.FORCE_TERMINAL = False diff --git a/cli/tests/test_cli.py b/cli/tests/test_cli.py new file mode 100644 index 0000000..b2bbd68 --- /dev/null +++ b/cli/tests/test_cli.py @@ -0,0 +1,17 @@ +import subprocess +import sys + +from typer.testing import CliRunner + +from covered import cli as mod + +runner = CliRunner() + + +def test_script(): # For coverage (if __name__ == "__main__":) + result = subprocess.run( + [sys.executable, "-m", "coverage", "run", mod.__file__, "--help"], + capture_output=True, + encoding="utf-8", + ) + assert "Usage" in result.stdout diff --git a/cli/tests/test_cli_options.py b/cli/tests/test_cli_options.py new file mode 100644 index 0000000..8f64cbb --- /dev/null +++ b/cli/tests/test_cli_options.py @@ -0,0 +1,175 @@ +""" +Tests for the Typer `upload` command - argument validation, env-var wiring, and defaults. +""" + +from pathlib import Path +from unittest.mock import AsyncMock + +from typer.testing import CliRunner + +from covered.cli import app + +from utils import COMMON_ENV + +runner = CliRunner() + + +def test_upload_rejects_api_url_with_trailing_slash( + mock_main: AsyncMock, tmp_path: Path +): + """ + `--api-url https://x/` (with trailing slash) exits non-zero with a clear message. + """ + env = {**COMMON_ENV, "COVERED_API_URL": "https://api.example.com/"} + + result = runner.invoke(app, [str(tmp_path)], env=env) + + assert result.exit_code != 0 + assert "must not end with a slash" in result.stderr + assert "--api-url" in result.stderr + mock_main.assert_not_awaited() + + +def test_upload_requires_existing_directory(mock_main: AsyncMock, tmp_path: Path): + """ + A directory argument that does not exist fails Typer's `exists=True` validation. + """ + missing = tmp_path / "does-not-exist" + + result = runner.invoke(app, [str(missing)], env=COMMON_ENV) + + assert result.exit_code != 0 + mock_main.assert_not_awaited() + + +def test_upload_rejects_file_as_directory_argument( + mock_main: AsyncMock, tmp_path: Path +): + """ + Passing a regular file (not a directory) fails validation because `file_okay=False`. + """ + file_path = tmp_path / "report.html" + file_path.write_text("") + + result = runner.invoke(app, [str(file_path)], env=COMMON_ENV) + + assert result.exit_code != 0 + mock_main.assert_not_awaited() + + +def test_upload_reads_all_options_from_env_vars(mock_main: AsyncMock, tmp_path: Path): + """ + Every `COVERED_*` env var is consumed and forwarded to `_main` as expected kwargs. + """ + env = { + **COMMON_ENV, + "COVERED_COVERAGE_THRESHOLD": "75.5", + "COVERED_PURGE_CACHE": "true", + } + + result = runner.invoke(app, [str(tmp_path)], env=env) + + assert result.exit_code == 0, result.stderr + kwargs = mock_main.call_args.kwargs + assert kwargs["directory"] == tmp_path + assert kwargs["api_url"] == "https://api.example.com" + assert kwargs["api_key"] == "test_api_key" + assert kwargs["repo_owner"] == "test_owner" + assert kwargs["repo_name"] == "test_repo" + assert kwargs["commit_sha"] == "test_commit_sha" + assert kwargs["gh_token"] == "test_github_token" + assert kwargs["coverage_threshold"] == 75.5 + assert kwargs["purge_cache"] is True + + +def test_upload_cli_flags_override_env_vars(mock_main: AsyncMock, tmp_path: Path): + """ + Explicit `--api-url` (and other flags) take precedence over the corresponding env + vars. + """ + result = runner.invoke( + app, + [str(tmp_path), "--api-url", "https://override.example.com"], + env=COMMON_ENV, + ) + + assert result.exit_code == 0, result.stderr + assert mock_main.call_args.kwargs["api_url"] == "https://override.example.com" + + +def test_upload_missing_required_option_fails(mock_main: AsyncMock, tmp_path: Path): + """ + Omitting a required option (e.g. `--api-key` / `COVERED_API_KEY`) exits non-zero + with a clear message. + """ + env = {k: v for k, v in COMMON_ENV.items() if k != "COVERED_API_KEY"} + + result = runner.invoke(app, [str(tmp_path)], env=env) + + assert result.exit_code != 0 + assert "--api-key" in result.stderr + mock_main.assert_not_awaited() + + +def test_upload_default_concurrency_is_50(mock_main: AsyncMock, tmp_path: Path): + """ + When `--concurrency` is not provided, `_main` receives `concurrency=50`. + """ + result = runner.invoke(app, [str(tmp_path)], env=COMMON_ENV) + + assert result.exit_code == 0, result.stderr + assert mock_main.call_args.kwargs["concurrency"] == 50 + + +def test_upload_default_coverage_threshold_is_100(mock_main: AsyncMock, tmp_path: Path): + """ + `--coverage-threshold` defaults to 100.0. + """ + result = runner.invoke(app, [str(tmp_path)], env=COMMON_ENV) + + assert result.exit_code == 0, result.stderr + assert mock_main.call_args.kwargs["coverage_threshold"] == 100.0 + + +def test_upload_purge_cache_flag_propagates_true(mock_main: AsyncMock, tmp_path: Path): + """ + `--purge-cache` results in `purge_cache=True` being passed to `_main`. + """ + result = runner.invoke(app, [str(tmp_path), "--purge-cache"], env=COMMON_ENV) + + assert result.exit_code == 0, result.stderr + assert mock_main.call_args.kwargs["purge_cache"] is True + + +def test_upload_no_purge_cache_default_is_false(mock_main: AsyncMock, tmp_path: Path): + """ + Without `--purge-cache` (and no env override), `_main` receives `purge_cache=False`. + """ + result = runner.invoke(app, [str(tmp_path)], env=COMMON_ENV) + + assert result.exit_code == 0, result.stderr + assert mock_main.call_args.kwargs["purge_cache"] is False + + +def test_upload_help_lists_all_options(): + """ + `--help` output mentions each documented option - sanity check against accidental + removal. + """ + result = runner.invoke(app, ["--help"], env={"COLUMNS": "200"}) + + assert result.exit_code == 0 + for option in [ + "DIRECTORY", + "--api-url", + "--api-key", + "--concurrency", + "--repo-owner", + "--repo-name", + "--commit-sha", + "--coverage-threshold", + "--gh-token", + "--is-default-branch", + "--purge-cache", + ]: + assert option in result.output, f"missing {option} in help output" diff --git a/cli/tests/test_coverage_parsing.py b/cli/tests/test_coverage_parsing.py new file mode 100644 index 0000000..e19e6be --- /dev/null +++ b/cli/tests/test_coverage_parsing.py @@ -0,0 +1,60 @@ +""" +Tests for `_get_coverage_info` - parsing coverage value from an HTML report. +""" + +from pathlib import Path + +from covered.cli import _get_coverage_info + + +def _write_index(directory: Path, html: str) -> None: + (directory / "index.html").write_text(html) + + +def test_get_coverage_info_returns_none_when_index_missing(tmp_path: Path): + """ + If the directory has no `index.html`, return `None`. + """ + assert _get_coverage_info(tmp_path) is None + + +def test_get_coverage_info_parses_pc_cov_span_template(tmp_path: Path): + """ + Extract the float value from `…%`. + """ + _write_index(tmp_path, '87.5%') + + assert _get_coverage_info(tmp_path) == 87.5 + + +def test_get_coverage_info_returns_none_when_no_pattern_matches(tmp_path: Path): + """ + `index.html` exists but contains no recognised template - return `None`. + """ + _write_index(tmp_path, "no coverage info here") + + assert _get_coverage_info(tmp_path) is None + + +def test_get_coverage_info_handles_decimal_values(tmp_path: Path): + """ + Values like `87.42` are parsed as `float`, not truncated to int. + """ + _write_index(tmp_path, '87.42%') + + result = _get_coverage_info(tmp_path) + + assert result == 87.42 + assert isinstance(result, float) + + +def test_get_coverage_info_handles_integer_values(tmp_path: Path): + """ + Values like `100` are parsed as `100.0`. + """ + _write_index(tmp_path, '100%') + + result = _get_coverage_info(tmp_path) + + assert result == 100.0 + assert isinstance(result, float) diff --git a/cli/tests/test_is_default_branch_deprecation.py b/cli/tests/test_is_default_branch_deprecation.py index 430e731..141f27d 100644 --- a/cli/tests/test_is_default_branch_deprecation.py +++ b/cli/tests/test_is_default_branch_deprecation.py @@ -1,25 +1,16 @@ -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest from typer.testing import CliRunner from covered.cli import app +from utils import COMMON_ENV runner = CliRunner() -common_env = { - "COVERED_API_KEY": "test_api_key", - "COVERED_API_URL": "https://api.example.com", - "COVERED_GH_TOKEN": "test_github_token", - "COVERED_REPO_OWNER": "test_owner", - "COVERED_REPO_NAME": "test_repo", - "COVERED_COMMIT_SHA": "test_commit_sha", -} - -def test_is_default_branch_true(): +def test_is_default_branch_true(mock_main: AsyncMock): with ( - patch("covered.cli._main", AsyncMock(return_value=None)) as mock_main, pytest.warns( DeprecationWarning, match=( @@ -28,7 +19,7 @@ def test_is_default_branch_true(): ), ), ): - result = runner.invoke(app, [".", "--is-default-branch"], env=common_env) + result = runner.invoke(app, [".", "--is-default-branch"], env=COMMON_ENV) assert result.exit_code == 0, result.stderr @@ -39,9 +30,8 @@ def test_is_default_branch_true(): assert purge_cache_arg is True -def test_is_default_branch_false(): +def test_is_default_branch_false(mock_main: AsyncMock): with ( - patch("covered.cli._main", AsyncMock(return_value=None)) as mock_main, pytest.warns( DeprecationWarning, match=( @@ -50,7 +40,7 @@ def test_is_default_branch_false(): ), ), ): - result = runner.invoke(app, [".", "--no-is-default-branch"], env=common_env) + result = runner.invoke(app, [".", "--no-is-default-branch"], env=COMMON_ENV) assert result.exit_code == 0, result.stderr @@ -68,9 +58,10 @@ def test_is_default_branch_false(): "--no-is-default-branch", ], ) -def test_is_default_branch_and_purge_cache(is_default_branch_flag: str): +def test_is_default_branch_and_purge_cache( + is_default_branch_flag: str, mock_main: AsyncMock +): with ( - patch("covered.cli._main", AsyncMock(return_value=None)) as mock_main, pytest.warns( DeprecationWarning, match=( @@ -80,7 +71,7 @@ def test_is_default_branch_and_purge_cache(is_default_branch_flag: str): ), ): result = runner.invoke( - app, [".", is_default_branch_flag, "--purge-cache"], env=common_env + app, [".", is_default_branch_flag, "--purge-cache"], env=COMMON_ENV ) assert result.exit_code == 0, result.stderr diff --git a/cli/tests/test_main.py b/cli/tests/test_main.py new file mode 100644 index 0000000..fd41049 --- /dev/null +++ b/cli/tests/test_main.py @@ -0,0 +1,427 @@ +""" +Tests for `_main` - orchestration of session creation, upload, status update, and cache +purge. +""" + +from pathlib import Path +from typing import Any, TypedDict +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +import typer + +from covered.cli import _main + +API_URL = "https://api.example.com" +API_KEY = "test_api_key" +REPO_OWNER = "owner" +REPO_NAME = "repo" +COMMIT_SHA = "abc123" +GH_TOKEN = "gh_xyz" +SITE_ID = "site-xyz" + +SESSION_RESP = { + "site_id": SITE_ID, + "bucket": "test-bucket", + "region": "us-east-1", + "access_key_id": "id", + "secret_access_key": "secret", + "session_token": "tok", +} + +CAMO_URL = "https://camo.githubusercontent.com/abc123def/xyz789ABC" +BADGE_HTML = ( + f'cov' +) + + +class PatchedMain(TypedDict): + """ + Handles to the three mocks installed by the `patched_main` fixture. + """ + + request: AsyncMock + upload: AsyncMock + coverage: MagicMock + + +def _main_kwargs(directory: Path, **overrides: Any) -> dict: + """ + Build a kwargs dict for `_main`; override only what each test cares about. + """ + return { + "directory": directory, + "api_url": API_URL, + "api_key": API_KEY, + "concurrency": 50, + "repo_owner": REPO_OWNER, + "repo_name": REPO_NAME, + "commit_sha": COMMIT_SHA, + "coverage_threshold": 90.0, + "gh_token": GH_TOKEN, + "purge_cache": False, + **overrides, + } + + +@pytest.fixture +def patched_main(monkeypatch: pytest.MonkeyPatch) -> PatchedMain: + """ + Patch `_main`'s three collaborators; defaults: 95% coverage, 5 files uploaded. + """ + request = AsyncMock() + upload = AsyncMock(return_value=5) + coverage = MagicMock(return_value=95.0) + + monkeypatch.setattr("covered.cli._request", request) + monkeypatch.setattr("covered.cli._upload_files", upload) + monkeypatch.setattr("covered.cli._get_coverage_info", coverage) + + return {"request": request, "upload": upload, "coverage": coverage} + + +async def test_main_creates_session_then_uploads_then_sets_status( + patched_main: PatchedMain, tmp_path: Path +): + """ + Happy path: session is created, files uploaded, GitHub status posted. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path)) + + calls = patched_main["request"].call_args_list + assert len(calls) == 2 + assert calls[0].args == ("POST", f"{API_URL}/coverage/create-site/") + patched_main["upload"].assert_awaited_once_with(tmp_path, SESSION_RESP, 50) + assert calls[1].args == ( + "POST", + f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/statuses/{COMMIT_SHA}", + ) + + +async def test_main_sets_success_status_when_coverage_meets_threshold( + patched_main: PatchedMain, tmp_path: Path +): + """ + When coverage > threshold, the posted status `state` is `success`. + """ + patched_main["coverage"].return_value = 95.0 + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path, coverage_threshold=90.0)) + + assert ( + patched_main["request"].call_args_list[1].kwargs["json"]["state"] == "success" + ) + + +async def test_main_sets_failure_status_when_coverage_below_threshold( + patched_main: PatchedMain, tmp_path: Path +): + """ + When coverage < threshold, the posted status `state` is `failure`. + """ + patched_main["coverage"].return_value = 80.0 + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path, coverage_threshold=90.0)) + + assert ( + patched_main["request"].call_args_list[1].kwargs["json"]["state"] == "failure" + ) + + +async def test_main_status_at_threshold_is_success( + patched_main: PatchedMain, tmp_path: Path +): + """ + Boundary: coverage exactly equal to threshold yields `success`, not `failure`. + """ + patched_main["coverage"].return_value = 90.0 + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path, coverage_threshold=90.0)) + + assert ( + patched_main["request"].call_args_list[1].kwargs["json"]["state"] == "success" + ) + + +async def test_main_skips_status_update_when_coverage_missing( + patched_main: PatchedMain, tmp_path: Path +): + """ + If `_get_coverage_info` returns None, no GitHub status call is made and `_main` + returns early. + """ + patched_main["coverage"].return_value = None + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + ] + + await _main(**_main_kwargs(tmp_path)) + + assert patched_main["request"].call_count == 1 + + +async def test_main_exits_with_error_when_status_post_fails( + patched_main: PatchedMain, tmp_path: Path +): + """ + Non-201 response from the GitHub status endpoint raises `typer.Exit(1)`. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(500, text="oops"), # github status failed + ] + + with pytest.raises(typer.Exit) as exc_info: + await _main(**_main_kwargs(tmp_path)) + + assert exc_info.value.exit_code == 1 + + +async def test_main_status_target_url_points_at_site( + patched_main: PatchedMain, tmp_path: Path +): + """ + The status payload's `target_url` is `{api_url}/coverage/{site_id}/`. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path)) + + payload = patched_main["request"].call_args_list[1].kwargs["json"] + assert payload["target_url"] == f"{API_URL}/coverage/{SITE_ID}/" + + +async def test_main_status_request_headers(patched_main: PatchedMain, tmp_path: Path): + """ + The status request uses `Authorization: Bearer ` and + `Accept: application/vnd.github+json`. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path)) + + headers = patched_main["request"].call_args_list[1].kwargs["headers"] + assert headers["Authorization"] == f"Bearer {GH_TOKEN}" + assert headers["Accept"] == "application/vnd.github+json" + + +async def test_main_skips_cache_purge_when_purge_cache_false( + patched_main: PatchedMain, tmp_path: Path +): + """ + When `purge_cache=False`, no invalidate-cache or README calls are made. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=False)) + + assert patched_main["request"].call_count == 2 + + +async def test_main_invalidates_cache_when_purge_cache_true( + patched_main: PatchedMain, tmp_path: Path +): + """ + When `purge_cache=True`, POST to /invalidate-cache/{owner}/{repo}/ with the token + header. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(200, text=""), # README (no badge match -> early return) + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + invalidate = patched_main["request"].call_args_list[2] + assert invalidate.args == ( + "POST", + f"{API_URL}/coverage/invalidate-cache/{REPO_OWNER}/{REPO_NAME}/", + ) + assert invalidate.kwargs["headers"] == {"token": API_KEY} + + +async def test_main_logs_failure_but_continues_when_invalidate_cache_fails( + patched_main: PatchedMain, tmp_path: Path +): + """ + A non-200 invalidate-cache response is logged but the flow continues to Camo purge. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(500, text="oops"), # invalidate-cache failed + httpx.Response(200, text=""), # README still attempted + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + # 4 calls: create-site, status, invalidate (failed), README + assert patched_main["request"].call_count == 4 + + +async def test_main_fetches_readme_with_html_accept_header( + patched_main: PatchedMain, tmp_path: Path +): + """ + The README request uses `Accept: application/vnd.github.html+json`. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(200, text=""), # README (no badge match -> early return) + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + readme = patched_main["request"].call_args_list[3] + assert readme.args == ( + "GET", + f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/readme", + ) + assert readme.kwargs["headers"]["Accept"] == "application/vnd.github.html+json" + assert readme.kwargs["headers"]["Authorization"] == f"Bearer {GH_TOKEN}" + + +async def test_main_purge_aborts_when_readme_fetch_fails( + patched_main: PatchedMain, tmp_path: Path +): + """ + A non-200 README response is logged and `_main` returns without sending PURGE. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(404), # README fetch failed + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + assert patched_main["request"].call_count == 4 + + +async def test_main_purge_aborts_when_badge_not_found_in_readme( + patched_main: PatchedMain, tmp_path: Path +): + """ + If the README has no matching ``, `_main` logs and returns - no PURGE call. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(200, text="no badge here"), # README (no badge) + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + assert patched_main["request"].call_count == 4 + + +async def test_main_purges_camo_url_extracted_from_readme( + patched_main: PatchedMain, tmp_path: Path +): + """ + PURGE is sent to the Camo URL captured by the badge regex from the README. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(200, text=BADGE_HTML), # README with matching badge + httpx.Response(200), # PURGE + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + purge = patched_main["request"].call_args_list[4] + assert purge.args == ("PURGE", CAMO_URL) + + +async def test_main_badge_regex_only_matches_camo_with_matching_canonical_src( + patched_main: PatchedMain, tmp_path: Path +): + """ + A Camo `` whose `data-canonical-src` does not match `api_url` is ignored. + """ + other_html = ( + f'' + ) + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(200, text=other_html), # README - badge points elsewhere + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + assert patched_main["request"].call_count == 4 + + +async def test_main_logs_failure_when_camo_purge_returns_non_200( + patched_main: PatchedMain, tmp_path: Path +): + """ + A non-200 PURGE response is logged; `_main` does not raise or exit non-zero. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + httpx.Response(200), # invalidate-cache + httpx.Response(200, text=BADGE_HTML), # README with matching badge + httpx.Response(500), # PURGE failed + ] + + await _main(**_main_kwargs(tmp_path, purge_cache=True)) + + assert patched_main["request"].call_count == 5 + + +async def test_main_create_site_uses_api_key_header_and_120s_timeout( + patched_main: PatchedMain, tmp_path: Path +): + """ + The create-site request sends the `token` header and uses the longer 120s timeout. + """ + patched_main["request"].side_effect = [ + httpx.Response(200, json=SESSION_RESP), # create-site + httpx.Response(201), # github status + ] + + await _main(**_main_kwargs(tmp_path)) + + create_call = patched_main["request"].call_args_list[0] + assert create_call.kwargs["headers"] == {"token": API_KEY} + assert create_call.kwargs["timeout"] == 120 diff --git a/cli/tests/test_request.py b/cli/tests/test_request.py new file mode 100644 index 0000000..4d90fa0 --- /dev/null +++ b/cli/tests/test_request.py @@ -0,0 +1,112 @@ +""" +Tests for `_request` - HTTP helper with retries via `stamina` (mocked with `respx`). +""" + +import json +from unittest.mock import patch + +import httpx +import pytest +import respx + +from covered.cli import _request + + +async def test_request_returns_response_on_first_success(respx_mock: respx.MockRouter): + """ + Single attempt, no retry; response object is returned. + """ + route = respx_mock.get("https://example.com").mock( + return_value=httpx.Response(200, text="ok") + ) + + resp = await _request("GET", "https://example.com") + + assert resp.status_code == 200 + assert resp.text == "ok" + assert route.call_count == 1 + + +async def test_request_retries_on_transport_error_then_succeeds( + respx_mock: respx.MockRouter, +): + """ + First call raises `httpx.TransportError`; retry succeeds and returns the response. + """ + route = respx_mock.get("https://example.com").mock( + side_effect=[httpx.ConnectError("boom"), httpx.Response(200)] + ) + + resp = await _request("GET", "https://example.com") + + assert resp.status_code == 200 + assert route.call_count == 2 + + +async def test_request_retries_on_5xx_then_succeeds(respx_mock: respx.MockRouter): + """ + First response is 503; retry returns 200 and that response is returned. + """ + route = respx_mock.get("https://example.com").mock( + side_effect=[httpx.Response(503), httpx.Response(200)] + ) + + resp = await _request("GET", "https://example.com") + + assert resp.status_code == 200 + assert route.call_count == 2 + + +async def test_request_does_not_retry_on_4xx(respx_mock: respx.MockRouter): + """ + A 404 response is returned without retry - only 5xx status triggers `raise_for_status`. + """ + route = respx_mock.get("https://example.com").mock(return_value=httpx.Response(404)) + + resp = await _request("GET", "https://example.com") + + assert resp.status_code == 404 + assert route.call_count == 1 + + +async def test_request_gives_up_after_three_attempts(respx_mock: respx.MockRouter): + """ + Three consecutive failures raise. + """ + route = respx_mock.get("https://example.com").mock(return_value=httpx.Response(500)) + + with pytest.raises(httpx.HTTPStatusError): + await _request("GET", "https://example.com") + + assert route.call_count == 3 + + +async def test_request_passes_parameters(respx_mock: respx.MockRouter): + """ + Parameters like `headers` and `json` are forwarded to the underlying httpx request. + """ + route = respx_mock.post("https://example.com/api").mock( + return_value=httpx.Response(200) + ) + + headers = {"X-Custom": "value", "Authorization": "Bearer abc"} + body = {"key": "value", "num": 42} + + await _request("POST", "https://example.com/api", headers=headers, json=body) + + request = route.calls.last.request + assert request.headers["X-Custom"] == "value" + assert request.headers["Authorization"] == "Bearer abc" + assert json.loads(request.content) == body + + +async def test_request_uses_configured_timeout(respx_mock: respx.MockRouter): + """ + The custom `timeout` value is propagated to the underlying `AsyncClient`. + """ + respx_mock.get("https://example.com").mock(return_value=httpx.Response(200)) + + with patch.object(httpx, "AsyncClient", wraps=httpx.AsyncClient) as spy: + await _request("GET", "https://example.com", timeout=5) + + spy.assert_called_once_with(timeout=5) diff --git a/cli/tests/test_upload_files.py b/cli/tests/test_upload_files.py new file mode 100644 index 0000000..d362ea1 --- /dev/null +++ b/cli/tests/test_upload_files.py @@ -0,0 +1,221 @@ +""" +Tests for `_upload_files` - concurrent S3 uploads via aiobotocore. +""" + +import asyncio +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from covered.cli import _upload_files + +BUCKET = "test-bucket" +SITE_ID = "site-abc" +SESSION = { + "site_id": SITE_ID, + "bucket": BUCKET, + "region": "us-east-1", + "access_key_id": "testing-key-id", + "secret_access_key": "testing-secret", + "session_token": "test-token", +} + + +class FakeS3Client: + """ + Aiobotocore-compatible stub: its own async context manager, exposes `put_object`. + """ + + def __init__(self, put_object: Any) -> None: + self.put_object = put_object + + async def __aenter__(self) -> "FakeS3Client": + return self + + async def __aexit__(self, *exc: object) -> None: + return None + + +@contextmanager +def patched_aiobotocore(put_object: Any) -> Iterator[MagicMock]: + """ + Patch `covered.cli.get_session` so the s3 client's `put_object` is `put_object`. + Yields the session mock so callers can inspect `create_client` args. + """ + fake_session = MagicMock() + fake_session.create_client = MagicMock( + return_value=FakeS3Client(put_object=put_object) + ) + with patch("covered.cli.get_session", return_value=fake_session): + yield fake_session + + +async def test_upload_files_uploads_every_file_recursively(tmp_path: Path): + """ + Every regular file in a nested directory tree is uploaded to S3. + """ + (tmp_path / "a.txt").write_text("aaa") + (tmp_path / "sub" / "deep").mkdir(parents=True) + (tmp_path / "sub" / "deep" / "b.txt").write_text("bbb") + (tmp_path / "sub" / "c.txt").write_text("ccc") + + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object): + count = await _upload_files(tmp_path, SESSION, concurrency=2) + + assert count == 3 + keys = {c.kwargs["Key"] for c in put_object.call_args_list} + assert keys == { + f"sites/{SITE_ID}/a.txt", + f"sites/{SITE_ID}/sub/deep/b.txt", + f"sites/{SITE_ID}/sub/c.txt", + } + + +async def test_upload_files_skips_directories(tmp_path: Path): + """ + Directory entries are not uploaded as objects (only files are). + """ + (tmp_path / "empty_subdir").mkdir() + (tmp_path / "another").mkdir() + (tmp_path / "another" / "file.txt").write_text("x") + + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object): + count = await _upload_files(tmp_path, SESSION, concurrency=2) + + assert count == 1 + keys = {c.kwargs["Key"] for c in put_object.call_args_list} + assert keys == {f"sites/{SITE_ID}/another/file.txt"} + + +async def test_upload_files_uses_relative_key_under_site_prefix(tmp_path: Path): + """ + S3 object key is `sites/{site_id}/{path-relative-to-directory}`. + """ + (tmp_path / "report.html").write_text("html") + + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object): + await _upload_files(tmp_path, SESSION, concurrency=1) + + put_object.assert_called_once_with( + Bucket=BUCKET, + Key=f"sites/{SITE_ID}/report.html", + Body=b"html", + ) + + +async def test_upload_files_returns_uploaded_count(tmp_path: Path): + """ + Return value equals the number of files in the tree. + """ + for i in range(5): + (tmp_path / f"f{i}.txt").write_text(str(i)) + + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object): + count = await _upload_files(tmp_path, SESSION, concurrency=2) + + assert count == 5 + assert put_object.call_count == 5 + + +async def test_upload_files_empty_directory_returns_zero(tmp_path: Path): + """ + Empty directory results in no S3 calls and a return value of 0. + """ + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object): + count = await _upload_files(tmp_path, SESSION, concurrency=2) + + assert count == 0 + put_object.assert_not_called() + + +async def test_upload_files_preserves_file_bytes(tmp_path: Path): + """ + The body sent to S3 matches `file_path.read_bytes()` exactly (binary-safe). + """ + binary = bytes(range(256)) + (tmp_path / "blob.bin").write_bytes(binary) + + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object): + await _upload_files(tmp_path, SESSION, concurrency=1) + + put_object.assert_called_once_with( + Bucket=BUCKET, + Key=f"sites/{SITE_ID}/blob.bin", + Body=binary, + ) + + +async def test_upload_files_passes_session_credentials_to_s3_client(tmp_path: Path): + """ + Region, access key, secret, token come from session dict. + """ + (tmp_path / "f.txt").write_text("x") + + put_object = AsyncMock(return_value={}) + with patched_aiobotocore(put_object) as fake_session: + await _upload_files(tmp_path, SESSION, concurrency=1) + + fake_session.create_client.assert_called_once_with( + "s3", + region_name="us-east-1", + aws_access_key_id="testing-key-id", + aws_secret_access_key="testing-secret", + aws_session_token="test-token", + ) + put_object.assert_called_once_with( + Bucket=BUCKET, + Key=f"sites/{SITE_ID}/f.txt", + Body=b"x", + ) + + +async def test_upload_files_respects_concurrency_limit(tmp_path: Path): + """ + The semaphore caps the number of in-flight uploads at the configured concurrency. + """ + n_files = 10 + for i in range(n_files): + (tmp_path / f"f{i}.txt").write_text(str(i)) + + in_flight = 0 + peak = 0 + + async def put_object(**kwargs: Any) -> dict: + nonlocal in_flight, peak + in_flight += 1 + peak = max(peak, in_flight) + await asyncio.sleep(0.01) + in_flight -= 1 + return {} + + with patched_aiobotocore(put_object): + await _upload_files(tmp_path, SESSION, concurrency=3) + + assert peak == 3 + + +async def test_upload_files_propagates_s3_errors(tmp_path: Path): + """ + If `put_object` raises, the exception is surfaced to the caller. + """ + (tmp_path / "f1.txt").write_text("x") + (tmp_path / "f2.txt").write_text("y") + + async def put_object(**kwargs: Any) -> dict: + raise RuntimeError("upload failed") + + with ( + patched_aiobotocore(put_object), + pytest.raises(RuntimeError, match="upload failed"), + ): + await _upload_files(tmp_path, SESSION, concurrency=2) diff --git a/cli/tests/utils.py b/cli/tests/utils.py new file mode 100644 index 0000000..956f358 --- /dev/null +++ b/cli/tests/utils.py @@ -0,0 +1,8 @@ +COMMON_ENV = { + "COVERED_API_KEY": "test_api_key", + "COVERED_API_URL": "https://api.example.com", + "COVERED_GH_TOKEN": "test_github_token", + "COVERED_REPO_OWNER": "test_owner", + "COVERED_REPO_NAME": "test_repo", + "COVERED_COMMIT_SHA": "test_commit_sha", +}