From 1cd2f6c388ed058bcd5b3239926ee4a60445ce57 Mon Sep 17 00:00:00 2001 From: Sergio Mattei Date: Sat, 25 Jul 2026 20:07:52 -0700 Subject: [PATCH] Add live Apple API end-to-end tests --- .github/workflows/e2e.yml | 41 ++++++++++++++ CONTRIBUTING.md | 11 +++- README.md | 9 ++- pyproject.toml | 1 + tests/e2e/test_live_apis.py | 110 ++++++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/e2e/test_live_apis.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..d6c7c0d --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,41 @@ +name: Live API E2E + +on: + push: + branches: [master] + paths: + - ".github/workflows/e2e.yml" + - "src/**" + - "tests/e2e/**" + - "pyproject.toml" + - "uv.lock" + schedule: + - cron: "17 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + COVERPY_RUN_E2E: "1" + UV_VERSION: "0.11.32" + +jobs: + live-api: + name: Apple live APIs + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + - run: uv python install 3.14 + - run: uv sync --locked --all-groups --python 3.14 + - run: uv run --python 3.14 pytest -m e2e tests/e2e --no-cov diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66011e2..0b3da82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,4 +21,13 @@ uv build --no-sources uvx --from twine twine check dist/* ``` -Tests must mock network access. Do not depend on the live Apple endpoint in the test suite. +The default suite mocks network access so pull requests remain deterministic. Run the +separate end-to-end suite to exercise the public iTunes API, Apple Music motion metadata, +HLS playlists, direct MP4 delivery, and the installed CLI against live services: + +```console +COVERPY_RUN_E2E=1 uv run pytest -m e2e tests/e2e --no-cov +``` + +GitHub Actions runs these tests after relevant pushes to `master`, every Monday, and on +demand. Live failures can indicate an Apple API change rather than a CoverPy regression. diff --git a/README.md b/README.md index 0df6e65..073b9c7 100644 --- a/README.md +++ b/README.md @@ -149,9 +149,16 @@ uv build --no-sources uvx --from twine twine check dist/* ``` +Run the opt-in end-to-end tests against Apple's live APIs: + +```console +COVERPY_RUN_E2E=1 uv run pytest -m e2e tests/e2e --no-cov +``` + The repository uses GitHub Actions instead of Travis CI. CI tests every supported Python version, checks formatting, linting, typing, coverage, and verifies both wheel and source -distributions. +distributions. A separate scheduled workflow validates the iTunes catalog, CLI, Apple Music +motion metadata, HLS playlists, and direct MP4 delivery against the live services. ## Releasing diff --git a/pyproject.toml b/pyproject.toml index c14aa71..aa5b213 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ build-backend = "uv_build" [tool.pytest.ini_options] addopts = "--cov=coverpy --cov-report=term-missing --cov-fail-under=95 -ra" +markers = ["e2e: calls Apple's live APIs and requires COVERPY_RUN_E2E=1"] testpaths = ["tests"] [tool.coverage.run] diff --git a/tests/e2e/test_live_apis.py b/tests/e2e/test_live_apis.py new file mode 100644 index 0000000..c18da7c --- /dev/null +++ b/tests/e2e/test_live_apis.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from urllib.parse import urlparse + +import pytest +import requests + +from coverpy import CoverPy + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif( + os.environ.get("COVERPY_RUN_E2E") != "1", + reason="set COVERPY_RUN_E2E=1 to call Apple's live APIs", + ), +] + +OK_COMPUTER_ID = 1097861387 +MOTION_ALBUM_ID = 1693323844 + + +def _assert_https_url(url: str | None, *, suffix: str | None = None) -> str: + assert url is not None + parsed = urlparse(url) + assert parsed.scheme == "https" + assert parsed.netloc + if suffix is not None: + assert parsed.path.endswith(suffix) + return url + + +def test_live_search_lookup_and_static_artwork() -> None: + with CoverPy(country="US", timeout=30) as client: + results = client.search("OK Computer Radiohead", limit=3) + lookup_results = client.lookup(OK_COMPUTER_ID) + + matching_album = next( + (result for result in results if result.artist_name == "Radiohead"), + None, + ) + assert matching_album is not None + assert matching_album.collection_name + assert matching_album.type == "album" + assert "1200x1200" in matching_album.artwork(1200) + _assert_https_url(matching_album.store_url) + + assert lookup_results + looked_up_album = lookup_results[0] + assert looked_up_album.collection_id == OK_COMPUTER_ID + assert looked_up_album.artist_name == "Radiohead" + _assert_https_url(looked_up_album.artwork_url) + + +def test_live_cli_searches_and_emits_json() -> None: + completed = subprocess.run( + [ + sys.executable, + "-m", + "coverpy.cli", + "OK Computer Radiohead", + "--limit", + "1", + "--json", + ], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == 0, completed.stderr + payload = json.loads(completed.stdout) + assert isinstance(payload, list) + assert len(payload) == 1 + assert payload[0]["artist"] == "Radiohead" + _assert_https_url(payload[0]["artwork_url"]) + + +def test_live_motion_artwork_resolves_playable_video() -> None: + with CoverPy(country="US", timeout=30) as client: + artwork = client.get_motion_artwork(MOTION_ALBUM_ID) + + assert artwork is not None + assert artwork.album_id == MOTION_ALBUM_ID + hls_url = _assert_https_url(artwork.hls_url, suffix=".m3u8") + _assert_https_url(artwork.tall_hls_url, suffix=".m3u8") + video_urls = { + _assert_https_url(artwork.video_url, suffix=".mp4"), + _assert_https_url(artwork.hq_video_url, suffix=".mp4"), + } + + hls_response = requests.get(hls_url, timeout=30) + hls_response.raise_for_status() + assert hls_response.text.startswith("#EXTM3U") + + for video_url in video_urls: + with requests.get( + video_url, + headers={"Range": "bytes=0-0"}, + stream=True, + timeout=30, + ) as response: + response.raise_for_status() + assert response.status_code in {200, 206} + assert response.headers.get("Content-Type", "").startswith("video/mp4") + assert next(response.iter_content(chunk_size=1))