mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-30 22:06:48 +01:00
Two independent reasons a working search reported failure to the user. 1. The client gave up before the server did (#1285) `/api/releases` bounds one release search with RELEASE_SEARCH_TIMEOUT (default 300s) and answers a spent budget with a sentence naming the real cause - the machinery added for #1276. The frontend then aborted the direct_download search at a hard-coded 180s, so it always won the race: the user saw "Request timed out. Check your network connection or proxy configuration." instead, and raising RELEASE_SEARCH_TIMEOUT changed nothing they could observe, the 180s being baked into the hashed bundle inside the image. - /api/config reports the effective (clamped) budget, and the client derives its abort from it plus a margin, so the server always answers first. - Direct-mode search shows what the server actually said. Every non-auth failure was relabelled "Unable to reach download source. Network may be restricted or mirrors blocked.", which discarded the explanation and blamed the user's network. ApiResponseError now carries `serverMessage`, set only when the server explained itself, so the status-line placeholder still falls back. Two latency fixes for the cost that made the timeout reachable at all: - Fetch each distinct AA search URL once per search. The language-filter retry re-runs every title variant, and with DIRECT_DOWNLOAD_LANGUAGE_FROM_PATH on both passes build a byte-identical URL - behind DDoS-Guard each repeat is a fresh browser solve. - Drop the solve-only bypass method. `_bypass_method_cdp_gui_click` opens with exactly that call and returns the moment it works, so the entry ahead of it could only repeat the half that had already failed, plus the backoff before the method that does work started. Reported at 0/19 successes and ~5.5s of each ~26s solve against DDoS-Guard. 2. The query carried every contributor, not one author (#1252) `_pick_search_author` returned `book.search_author` verbatim while the authors[] fallback beside it deliberately narrowed to the first name before a comma. Both fields routinely arrive holding every contributor joined with ", ": the frontend builds `book.author` as `authors.join(', ')` for display (bookTransformers.ts) and the release modal sends that display string straight back as the `author` parameter, and `browse_record_to_book_metadata` and the manual-search branch both split the joined text into `authors` while still passing the unsplit string as `search_author`, so the split was never used. A book whose metadata lists translators was therefore searched for as Blindness Jose Saramago, Giovanni Pontiero, <persian translator> which matches nothing on Anna's Archive. The bypass succeeds, the search comes back empty, and the user is told the book has no releases. Narrowed in one place, `search_plan.first_author`, so the two branches cannot drift apart again, and applied to the IRC source, which built its query with the same verbatim preference. Hardcover is unaffected: it already sets `search_author` from `_simplify_author_for_search(authors[0])`, which resolves "Last, First" itself and never yields a multi-author string.
89 lines
3.2 KiB
Python
89 lines
3.2 KiB
Python
"""No method in the list may be a step another method already takes first.
|
|
|
|
`BYPASS_METHODS` used to open with a solve-only entry that called `page.solve_captcha()`
|
|
and checked the result. `_bypass_method_cdp_gui_click`, the entry behind it, opens by
|
|
doing exactly that and returns the moment it works - so against a challenge that
|
|
`solve_captcha()` cannot clear, the first method could only repeat the half that had
|
|
already failed, then charge the loop's backoff before the method that does work started.
|
|
Measured on Anna's Archive at 0/19 successes and ~5.5s of the ~26s each solve cost
|
|
(issue #1285).
|
|
|
|
The passive-solve window added in v1.3.13 keeps most solves away from this loop entirely,
|
|
so this is about what the loop costs when it does run.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
import shelfmark.bypass.internal_bypasser as ib
|
|
|
|
|
|
@pytest.fixture
|
|
def no_sleep(monkeypatch):
|
|
async def _no_sleep(_seconds) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(ib.asyncio, "sleep", _no_sleep)
|
|
monkeypatch.setattr(ib._RNG, "uniform", lambda _a, _b: 0)
|
|
|
|
|
|
class _Page:
|
|
"""Records what a method asked the page to do."""
|
|
|
|
def __init__(self, *, solve_clears: bool) -> None:
|
|
self.solve_clears = solve_clears
|
|
self.calls: list[str] = []
|
|
|
|
async def solve_captcha(self) -> None:
|
|
self.calls.append("solve_captcha")
|
|
|
|
async def is_element_visible(self, selector: str) -> bool:
|
|
self.calls.append(f"visible:{selector}")
|
|
return False
|
|
|
|
async def click_with_offset(self, selector: str, _x, _y, center=True) -> None:
|
|
self.calls.append(f"click:{selector}")
|
|
|
|
|
|
def _stub_is_bypassed(monkeypatch, page: _Page) -> None:
|
|
async def _is_bypassed(*_a, **_kw) -> bool:
|
|
return page.solve_clears and "solve_captcha" in page.calls
|
|
|
|
monkeypatch.setattr(ib, "_is_bypassed", _is_bypassed)
|
|
|
|
|
|
def test_no_solve_only_method_remains_in_the_list():
|
|
names = [method.__name__ for method in ib.BYPASS_METHODS]
|
|
assert "_bypass_method_cdp_solve" not in names
|
|
assert names[0] == "_bypass_method_cdp_gui_click"
|
|
|
|
|
|
def test_the_first_method_still_tries_solve_captcha_first(monkeypatch, no_sleep):
|
|
"""Coverage is only preserved because gui_click opens with the same call."""
|
|
page = _Page(solve_clears=True)
|
|
_stub_is_bypassed(monkeypatch, page)
|
|
|
|
assert asyncio.run(ib.BYPASS_METHODS[0](page)) is True
|
|
assert page.calls == ["solve_captcha"], "it must return before touching any selector"
|
|
|
|
|
|
def test_it_falls_through_to_clicking_when_solve_does_not_clear(monkeypatch, no_sleep):
|
|
"""The half that actually works on DDoS-Guard still runs in the same attempt."""
|
|
page = _Page(solve_clears=False)
|
|
_stub_is_bypassed(monkeypatch, page)
|
|
|
|
assert asyncio.run(ib.BYPASS_METHODS[0](page)) is False
|
|
assert page.calls[0] == "solve_captcha"
|
|
assert any(call.startswith("visible:") for call in page.calls), (
|
|
"the selector pass should have been reached in the same attempt"
|
|
)
|
|
|
|
|
|
def test_the_derived_budgets_follow_the_shortened_list():
|
|
"""Both budgets are computed from the list, so removing an entry must not strand them."""
|
|
assert ib._BYPASS_METHOD_ATTEMPTS == len(ib.BYPASS_METHODS) + 1
|
|
assert ib._BYPASS_METHOD_ATTEMPTS >= len(ib.BYPASS_METHODS), (
|
|
"every method must still get a turn"
|
|
)
|