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'
'
+)
+
+
+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",
+}