"""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