Files
wordarr/tests/test_ebooks.py

336 lines
14 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",
"language": {"code2": "de"},
}
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")) == []
def test_hardcover_keeps_a_book_whose_editions_carry_no_language(monkeypatch):
"""German translations are often their own book row with untagged editions."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {
"editions": [{"book_id": 42, "title": "Der Hobbit", "language": None}],
"other": [],
}})
return httpx.Response(200, json={"data": {"search": {"results": {"hits": [HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hit = asyncio.run(hardcover.search("hobbit", "german"))[0]
assert (hit.title, hit.language) == ("Der Hobbit", "")
def test_hardcover_drops_an_untagged_edition_of_a_foreign_book(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": [{"book_id": 42, "title": "The Hobbit", "language": None}],
"other": [{"book_id": 42}],
}})
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,
"language": {"code2": "de"}},
])
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"]
INTRUDER_ROWS = {
"name": "Villains",
"book_series": [
{"position": 1, "book": {
"id": 1, "title": "Vicious", "users_count": 1833,
"contributions": [{"author": {"name": "V. E. Schwab"}}]}},
{"position": 1, "book": {
"id": 2, "title": "Die Schoenste im ganzen Land", "users_count": 1,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
{"position": 2, "book": {
"id": 3, "title": "Coeur de pierre", "users_count": 1,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
{"position": 2, "book": {
"id": 4, "title": "Cold Hearted", "users_count": 76,
"contributions": [{"author": {"name": "Serena Valentino"}}]}},
],
}
def test_hardcover_series_skips_a_foreign_book_filed_under_a_volume(monkeypatch):
"""Hardcover lets anyone file a book into a series - "Vicious" sits on volume
1 of the Disney Villains and has a German edition and 1833 readers, so both
tie breakers would hand it the slot."""
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": [
{"book_id": 1, "title": "Vicious - Das Boese in uns"},
{"book_id": 2, "title": "Die Schoenste im ganzen Land"},
]}})
return httpx.Response(200, json={"data": {"series": [INTRUDER_ROWS]}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
books = asyncio.run(hardcover.series_books("976", "german"))
# volume 2 has no German edition: the most read row beats the French one
assert [b.title for b in books] == [
"Die Schoenste im ganzen Land", "Cold Hearted"]
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
def test_hardcover_puts_hits_of_unknown_language_last(monkeypatch):
untagged = {"document": {**HARDCOVER_HIT["document"], "id": "7", "title": "Bundle"}}
def handler(request: httpx.Request) -> httpx.Response:
import json
if "editions" in json.loads(request.content)["query"]:
return httpx.Response(200, json={"data": {"editions": [
{"book_id": 7, "title": "Bundle", "language": None},
GERMAN_EDITION,
]}})
return httpx.Response(200, json={"data": {"search": {"results": {
"hits": [untagged, HARDCOVER_HIT]}}}})
monkeypatch.setattr(hardcover, "TOKEN", "test-token")
mock_client(monkeypatch, hardcover, handler)
hits = asyncio.run(hardcover.search("hobbit", "german"))
assert [h.title for h in hits] == ["Der Hobbit", "Bundle"]