Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Tests

on:
push:
branches:
- master
pull_request:

permissions:
contents: read

# A newer push to the same branch makes the running check obsolete
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
pytest:
name: pytest on Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v7

- name: Install uv
uses: astral-sh/setup-uv@v9.0.0
with:
python-version: ${{ matrix.python-version }}
enable-cache: true

- name: Install the project
# --locked fails when uv.lock no longer matches pyproject.toml, which
# doubles as the lockfile check the pull request workflows lack
run: uv sync --locked

- name: Run tests
run: uv run --no-sync pytest -v
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ audible-quickstart = "audible_cli:quickstart"

[dependency-groups]
dev = [
"pyinstaller"
"pyinstaller",
"pytest>=8.4.2"
]

[tool.hatch.build.targets.sdist]
Expand Down Expand Up @@ -150,4 +151,4 @@ lines-after-imports = 2
"tests/*" = ["S101"]

[tool.pytest.ini_options]
testpaths = ["tests", "src/audible_cli"]
testpaths = ["tests"]
4 changes: 0 additions & 4 deletions test.py

This file was deleted.

7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import pytest
from helpers import FakeClient


@pytest.fixture
def client():
return FakeClient()
51 changes: 51 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Shared stand-ins for the tests.

Kept out of ``conftest.py`` on purpose: pytest discourages importing from a
conftest module, so anything the tests import directly lives here instead.
"""

import json

import httpx


class FakeClient:
"""Stands in for ``audible.AsyncClient`` in the library code paths.

Only the two calls the models make are implemented. ``last_params``
records what a request was sent with, so tests can assert on the
serialized query.
"""

def __init__(self, items=(), license_response=None):
self.items = list(items)
self.license_response = license_response
self.last_params = None

async def get(self, path, response_callback=None, **params):
self.last_params = params
body = json.dumps({"items": self.items}).encode()
return httpx.Response(
200,
content=body,
headers={
"content-type": "application/json",
"total-count": str(len(self.items)),
},
request=httpx.Request("GET", f"https://example.invalid/{path}"),
)

async def post(self, path, body=None, headers=None):
return self.license_response


def library_item(asin, **fields):
"""A library item with only the fields the code under test reads."""
item = {
"asin": asin,
"title": f"Title {asin}",
"content_delivery_type": "SinglePartBook",
"has_children": False,
}
item.update(fields)
return item
109 changes: 109 additions & 0 deletions tests/test_download_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""The download command must not report success after a failed job.

Covers the wiring for #256: `drain_queue()` collecting failures is only half
of it, the command also has to raise on them. Without this test, deleting the
`run.raise_for_errors()` call would leave every other test green.
"""

import pytest
from click.testing import CliRunner

from audible_cli.cmds import cmd_download
from audible_cli.config import Session
from audible_cli.exceptions import AudibleCliException
from audible_cli.models import Library


class FakeHTTPSession:
"""Stands in for the httpx session `pass_client` opens."""

async def __aenter__(self):
return self

async def __aexit__(self, *exc_info):
return False


class FakeApiClient:
def __init__(self):
self.session = FakeHTTPSession()


@pytest.fixture
def session(monkeypatch):
"""A session handing out a client that never reaches the network."""
monkeypatch.setattr(Session, "get_client", lambda self, **kw: FakeApiClient())
return Session()


@pytest.fixture
def one_item_library(monkeypatch):
"""Work on a single known item instead of fetching a real library."""

async def fake_from_api_full_sync(cls, api_client, **request_params):
return Library(
{
"items": [
{
"asin": "ASIN0001",
"title": "A Title",
"content_delivery_type": "SinglePartBook",
"has_children": False,
"purchase_date": "2020-01-01T00:00:00.000Z",
}
]
},
api_client=api_client,
)

monkeypatch.setattr(
Library, "from_api_full_sync", classmethod(fake_from_api_full_sync)
)


def download(session, tmp_path, *extra):
# --chapter-type and --filename-mode are passed explicitly so the command
# never falls back to reading the user's config file
return CliRunner().invoke(
cmd_download.cli,
[
"--asin", "ASIN0001",
"--cover",
"--output-dir", str(tmp_path),
"--filename-mode", "ascii",
"--chapter-type", "flat",
*extra,
],
obj=session,
)


@pytest.mark.parametrize("extra", [(), ("--ignore-errors",)])
def test_raises_when_a_job_failed(
monkeypatch, session, one_item_library, tmp_path, extra
):
async def failing_cover(**kwargs):
raise RuntimeError("cover download failed")

monkeypatch.setattr(cmd_download, "download_cover", failing_cover)

result = download(session, tmp_path, *extra)

# cli.main() maps this to exit code 2; here the exception itself is what
# proves the command did not swallow the failure
assert isinstance(result.exception, AudibleCliException), result.output
assert "job(s) failed" in str(result.exception)


def test_succeeds_when_every_job_worked(
monkeypatch, session, one_item_library, tmp_path
):
async def working_cover(**kwargs):
return None

monkeypatch.setattr(cmd_download, "download_cover", working_cover)

result = download(session, tmp_path)

assert result.exception is None, result.output
assert result.exit_code == 0
Loading