Files
wordarr/tests/test_ebooks.py
T

243 lines
9.3 KiB
Python

"""The three ebook providers and the search that merges them."""
import asyncio
import httpx
import pytest
from wordarr import metadata
from wordarr.metadata import googlebooks, hardcover, openlibrary
HARDCOVER_HIT = {
"document": {
"id": "42",
"title": "The Hobbit",
"author_names": ["J. R. R. Tolkien"],
"release_year": 1937,
"isbns": ["9780261102217"],
"image": {"url": "https://example.invalid/hobbit.jpg"},
"series_names": [],
}
}
GERMAN_EDITION = {
"book_id": 42,
"title": "Der Hobbit",
"isbn_13": "9783608939828",
"isbn_10": None,
"release_date": "1957-03-01",
"cached_image": "https://example.invalid/hobbit-de.jpg",
}
def mock_client(monkeypatch, module, handler):
real = httpx.AsyncClient
monkeypatch.setattr(module.httpx, "AsyncClient",
lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
@pytest.fixture
def hardcover_calls(monkeypatch):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen.append((body["variables"], request.headers.get("authorization")))
if "editions" in body["query"]:
return httpx.Response(200, json={"data": {"editions": [GERMAN_EDITION]}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
return seen
def test_hardcover_needs_no_network_without_a_token(monkeypatch):
monkeypatch.setattr(hardcover, "TOKEN", "")
assert asyncio.run(hardcover.search("hobbit")) == []
def test_hardcover_parses_a_hit(hardcover_calls):
hit = asyncio.run(hardcover.search("hobbit"))[0]
assert (hit.title, hit.authors, hit.year) == ("The Hobbit", "J. R. R. Tolkien", 1937)
assert hit.external_id == "9780261102217"
assert hit.source == "hardcover"
assert hardcover_calls[0][1] == "Bearer test-token"
def test_hardcover_swaps_in_the_german_edition(hardcover_calls):
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
assert (hit.title, hit.year, hit.language) == ("Der Hobbit", 1957, "german")
assert hit.external_id == "9783608939828"
assert hardcover_calls[1][0] == {"ids": [42], "lang": "de"}
def test_hardcover_drops_books_without_an_edition_in_that_language(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": []}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert asyncio.run(hardcover.search("hobbit", "german")) == []
SERIES_ROWS = {
"name": "Die drei Fragezeichen",
"book_series": [
{"position": 1, "book": {
"id": 1, "title": "Super-Papagei", "release_year": 1968,
"contributions": [{"author": {"name": "Robert Arthur"}}],
"cached_image": {"url": "https://example.invalid/1.jpg"}}},
{"position": 2, "book": {"id": 2, "title": "Misteri Danau"}},
{"position": 2, "book": {"id": 3, "title": "Der Phantomsee"}},
{"position": 2, "book": {"id": 2, "title": "duplicate row"}},
{"position": 0, "book": {"id": 4, "title": "Sonderband"}},
],
}
def series_client(monkeypatch, editions):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content)
seen.append(body["variables"])
if "editions" in body["query"]:
return httpx.Response(200, json={"data": {"editions": editions}})
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
return seen
def test_hardcover_series_sorts_by_volume_and_keeps_the_first_row(monkeypatch):
series_client(monkeypatch, [])
books = asyncio.run(hardcover.series_books("10146"))
assert [(b.volume, b.title) for b in books] == [
(1, "Super-Papagei"), (2, "Misteri Danau"), (None, "Sonderband"),
]
first = books[0]
assert (first.series, first.series_id, first.authors, first.media_type) == (
"Die drei Fragezeichen", "10146", "Robert Arthur", "ebook")
assert first.cover_url == "https://example.invalid/1.jpg"
def test_hardcover_series_prefers_the_row_with_an_edition_in_the_language(monkeypatch):
calls = series_client(monkeypatch, [
{"book_id": 3, "title": "Der Phantomsee", "isbn_13": "9783440032251",
"release_date": "1969-01-01", "cached_image": None},
])
books = asyncio.run(hardcover.series_books("10146", "german"))
assert calls[1] == {"ids": [1, 2, 3, 4], "lang": "de"}
second = next(b for b in books if b.volume == 2)
assert (second.title, second.language, second.external_id, second.year) == (
"Der Phantomsee", "german", "9783440032251", 1969)
# a volume without a German edition keeps its original row instead of vanishing
assert [b.title for b in books] == ["Super-Papagei", "Der Phantomsee", "Sonderband"]
def test_hardcover_series_survives_a_broken_edition_lookup(monkeypatch):
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(500)
return httpx.Response(200, json={"data": {"series": [SERIES_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
assert len(asyncio.run(hardcover.series_books("10146", "german"))) == 3
def test_hardcover_series_needs_a_numeric_id(monkeypatch):
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
assert asyncio.run(hardcover.series_books("")) == []
def test_google_books_restricts_the_language(monkeypatch):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(dict(request.url.params))
return httpx.Response(200, json={"items": [{"volumeInfo": {
"title": "Der Hobbit",
"authors": ["J. R. R. Tolkien"],
"publishedDate": "1957",
"language": "de",
"industryIdentifiers": [{"type": "ISBN_13", "identifier": "9783608939828"}],
"imageLinks": {"thumbnail": "http://example.invalid/de.jpg"},
}}]})
mock_client(monkeypatch, googlebooks, handler)
hit = asyncio.run(googlebooks.search("hobbit", "german"))[0]
assert seen[0]["langRestrict"] == "de"
assert (hit.title, hit.language, hit.year) == ("Der Hobbit", "german", 1957)
assert hit.cover_url.startswith("https://") # the API still hands out http
def test_google_books_searches_an_isbn_as_an_isbn(monkeypatch):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(dict(request.url.params))
return httpx.Response(200, json={"items": []})
mock_client(monkeypatch, googlebooks, handler)
asyncio.run(googlebooks.search("978-3-608-93982-8"))
assert seen[0]["q"] == "isbn:9783608939828"
def test_open_library_filters_and_labels_the_language(monkeypatch):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(dict(request.url.params))
return httpx.Response(200, json={"docs": [{
"title": "Der Hobbit",
"author_name": ["J. R. R. Tolkien"],
"first_publish_year": 1957,
"language": ["ger", "eng"],
"isbn": ["9783608939828"],
}]})
mock_client(monkeypatch, openlibrary, handler)
hit = asyncio.run(openlibrary.search("hobbit", "german"))[0]
assert seen[0]["q"] == "hobbit language:ger"
assert hit.language == "german"
assert hit.source == "openlibrary"
def test_ebook_search_merges_the_providers_and_survives_a_broken_one(monkeypatch):
async def hits(source, **fields):
return [metadata.MetadataResult(media_type="ebook", source=source, **fields)]
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
monkeypatch.setattr(hardcover, "search", lambda q, lang="", fb=True: hits(
"hardcover", title="Der Hobbit", external_id="9783608939828"))
monkeypatch.setattr(googlebooks, "search", lambda q, lang="", fb=True: hits(
"googlebooks", title="Der Hobbit", external_id="9783608939828")) # same edition
async def boom(q, lang="", fb=True):
raise httpx.ConnectError("open library is down")
monkeypatch.setattr(openlibrary, "search", boom)
results = asyncio.run(metadata.ebook_search("hobbit", "german"))
assert [r.source for r in results] == ["hardcover"]
def test_automatic_search_asks_only_the_best_provider(monkeypatch):
called = []
async def record(name, q, lang):
called.append(name)
return []
monkeypatch.setattr(hardcover, "TOKEN", "")
monkeypatch.setattr(googlebooks, "search", lambda q, lang="", fb=True: record("gb", q, lang))
monkeypatch.setattr(openlibrary, "search", lambda q, lang="", fb=True: record("ol", q, lang))
asyncio.run(metadata.ebook_search("hobbit", "german", fallback=False))
assert called == ["gb"] # no token, so Google Books stands in for Hardcover