Files
shelfmark/tests/core/test_release_search.py
T
48ae3dbef4 feat: optional Hardcover wishlist sync with automated downloading (1 of 5) (#1516)
This replaces #1047, which @InfiniteAvenger opened and calibrain offered
to re-review. It is Caleb's feature commit with his authorship kept,
rebased onto current main, with small fixes, tests and a docs page on
top. Everything is opt-in and off by default, so nothing changes for
anyone who doesn't turn it on.

What it adds is a **Settings, Hardcover Sync** tab with three parts that
can be switched on separately:

- **Shelf sync**: a Hardcover shelf (Want to Read by default) is pulled
into requests on a schedule, or with a Sync now button. Requests carry
the Hardcover metadata and cover.
- **Automatic downloads**: synced requests are approved and a release is
picked from a source priority list. A strict title, author and format
match (plus a seeder minimum for torrents) decides, so anything without
a confident match stays pending for manual review.
- **Skip books you already own**: sync and automatic downloads ask
whichever libraries are enabled under Library Check (Calibre today) and
skip a book that is already there.

On top of Caleb's commit I made the request owner a lookup instead of
assuming user id 1, sent synced cover URLs through the proxy when the
request is created, got ruff and basedpyright clean, and added tests and
`docs/hardcover-sync.md`. His commit also had its own Audiobookshelf
settings and a Test library connection button, written before the
library check was split into providers. The button would have raised a
TypeError against the current `test_connection`, so those settings are
gone from this tab and an Audiobookshelf provider comes as a separate
PR, which is the part of #1047 that is missing here (for now
anyways...).

I've run this daily for about a couple of weeks. Ruff, basedpyright and
the backend suite pass (4426 passed).

This is 1 of 5. As calibrain suggested, the rest are stacked on this
one, each saying which number it is.

Closes
https://github.com/calibrain/shelfmark/pull/1047#issuecomment-6302071588

---------

Co-authored-by: InfiniteAvenger <calebewest02@gmail.com>
Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:27:47 -04:00

175 lines
5.6 KiB
Python

"""Tests for the per-source release search helper extracted from ``/api/releases``."""
from __future__ import annotations
from typing import Any
import pytest
from shelfmark.core import release_search
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources import SourceUnavailableError
def _book(**overrides: Any) -> BookMetadata:
fields: dict[str, Any] = {
"provider": "manual",
"provider_id": "1",
"title": "Dungeon Crawler Carl",
"authors": ["Matt Dinniman"],
"isbn_13": "9780593820247",
}
fields.update(overrides)
return BookMetadata(**fields)
class _Source:
def __init__(self, result: list[str] | None = None, error: Exception | None = None) -> None:
self.result = result if result is not None else []
self.error = error
self.calls: list[tuple[Any, Any, bool, str]] = []
def search(
self, book: BookMetadata, plan: Any, *, expand_search: bool, content_type: str
) -> list[str]:
self.calls.append((book, plan, expand_search, content_type))
if self.error is not None:
raise self.error
return list(self.result)
def test_unknown_source_returns_error_tuple(monkeypatch: pytest.MonkeyPatch) -> None:
def _raise(name: str) -> Any:
msg = f"Unknown source: {name}"
raise ValueError(msg)
monkeypatch.setattr("shelfmark.release_sources.get_source", _raise)
source, releases, error = release_search.search_source_releases("nope", _book())
assert source is None
assert releases == []
assert error == "Unknown source: nope"
@pytest.mark.parametrize(
"exc",
[SourceUnavailableError("down"), OSError("boom"), RuntimeError("bad"), ValueError("odd")],
ids=["unavailable", "oserror", "runtime", "valueerror"],
)
def test_source_failures_become_messages(monkeypatch: pytest.MonkeyPatch, exc: Exception) -> None:
src = _Source(error=exc)
monkeypatch.setattr("shelfmark.release_sources.get_source", lambda _name: src)
source, releases, error = release_search.search_source_releases("s", _book())
assert source is None
assert releases == []
assert error == f"s: {exc!s}"
def test_success_returns_source_and_forwards_search_kwargs(
monkeypatch: pytest.MonkeyPatch,
) -> None:
src = _Source(result=["r1", "r2"])
monkeypatch.setattr("shelfmark.release_sources.get_source", lambda _name: src)
source, releases, error = release_search.search_source_releases(
"s", _book(), expand_search=True, content_type="audiobook"
)
assert source is src
assert releases == ["r1", "r2"]
assert error is None
book, plan, expand_search, content_type = src.calls[0]
assert book.title == "Dungeon Crawler Carl"
assert "dungeon crawler carl" in plan.primary_query.lower()
assert plan.isbn_candidates == ["9780593820247"]
assert expand_search is True
assert content_type == "audiobook"
def test_plan_receives_filters_and_user_id(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict[str, Any] = {}
real_build = release_search.build_release_search_plan
def _build(book: BookMetadata, **kwargs: Any) -> Any:
captured.update(kwargs)
return real_build(book, **kwargs)
monkeypatch.setattr(release_search, "build_release_search_plan", _build)
src = _Source()
monkeypatch.setattr("shelfmark.release_sources.get_source", lambda _name: src)
release_search.search_source_releases(
"s",
_book(),
languages=["en"],
manual_query="dcc",
indexers=["idx"],
user_id=42,
)
assert captured == {
"languages": ["en"],
"manual_query": "dcc",
"indexers": ["idx"],
"source_filters": None,
"user_id": 42,
}
def test_search_book_releases_preserves_source_order_and_aggregates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sources = {"a": _Source(result=["a1"]), "b": _Source(result=["b1", "b2"])}
monkeypatch.setattr("shelfmark.release_sources.get_source", lambda name: sources[name])
all_releases, by_source, errors = release_search.search_book_releases(
_book(), sources=["b", "a"], content_type="audiobook"
)
assert list(by_source) == ["b", "a"]
assert by_source == {"b": ["b1", "b2"], "a": ["a1"]}
assert all_releases == ["b1", "b2", "a1"]
assert errors == []
_book_arg, _plan, expand_search, content_type = sources["a"].calls[0]
assert expand_search is True
assert content_type == "audiobook"
def test_search_book_releases_collects_errors_and_keeps_healthy_results(
monkeypatch: pytest.MonkeyPatch,
) -> None:
sources = {
"down": _Source(error=SourceUnavailableError("offline")),
"ok": _Source(result=["r1"]),
}
def _get(name: str) -> Any:
if name not in sources:
msg = f"Unknown release source: {name}"
raise ValueError(msg)
return sources[name]
monkeypatch.setattr("shelfmark.release_sources.get_source", _get)
all_releases, by_source, errors = release_search.search_book_releases(
_book(), sources=["down", "ok", "nope"]
)
assert all_releases == ["r1"]
assert by_source == {"ok": ["r1"]}
assert errors == ["down: offline", "Unknown source: nope"]
def test_search_book_releases_with_no_sources_returns_empty(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _unexpected(_name: str) -> Any:
raise AssertionError("no source should be resolved")
monkeypatch.setattr("shelfmark.release_sources.get_source", _unexpected)
assert release_search.search_book_releases(_book(), sources=[]) == ([], {}, [])