Files
shelfmark/src/frontend/src/tests/searchFailureMessage.test.ts
T
CaliBrain 3d7ea40088 fix(search): reach the server's deadline, query one author (#1285, #1252) (#1287)
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.
2026-09-01 12:38:57 -04:00

78 lines
3.1 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import { searchBooks } from '../services/api';
import {
describeSearchFailure,
CLIENT_TIMEOUT_MESSAGE,
UNREACHABLE_SOURCE_MESSAGE,
} from '../utils/searchFailureMessage';
/**
* What a failed direct-mode search tells the user.
*
* Every non-auth failure used to be relabelled "Unable to reach download source. Network
* may be restricted or mirrors blocked.", which threw away the server's explanation and
* blamed the user's network for a protection challenge. See issue #1285.
*/
const jsonResponse = (body: unknown, status: number): Response =>
new Response(JSON.stringify(body), {
status,
statusText: 'SERVICE UNAVAILABLE',
headers: { 'Content-Type': 'application/json' },
});
/** Drive a real searchBooks() failure so the error is the one the hook actually sees. */
const failedSearch = async (respond: () => Promise<Response>): Promise<unknown> => {
vi.stubGlobal('fetch', vi.fn(respond));
return searchBooks('q=dune').catch((error: unknown) => error);
};
describe('describeSearchFailure', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('shows the sentence the server sent', async () => {
const sentence = 'The release search ran out of time (300s).';
const error = await failedSearch(() => Promise.resolve(jsonResponse({ error: sentence }, 503)));
expect(describeSearchFailure(error)).toBe(sentence);
});
it('names the wait when the client gave up first', async () => {
// The client's abort is the backstop for a server that never answered. It tells us
// nothing about mirrors or the network, and the old chain reported it as if it did.
const abort = Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' });
const error = await failedSearch(() => Promise.reject(abort));
expect(describeSearchFailure(error)).toBe(CLIENT_TIMEOUT_MESSAGE);
expect(describeSearchFailure(error)).not.toBe(UNREACHABLE_SOURCE_MESSAGE);
expect(describeSearchFailure(error)).not.toContain('mirrors');
});
it('falls back to the mirrors line only when nothing explained itself', async () => {
const error = await failedSearch(() => Promise.resolve(jsonResponse({}, 503)));
expect(describeSearchFailure(error)).toBe(UNREACHABLE_SOURCE_MESSAGE);
});
it('keeps a reachability message that already says the right thing', () => {
const error = new Error('Unable to reach download source. Every mirror was quarantined.');
expect(describeSearchFailure(error)).toBe(error.message);
});
it('never produces an empty sentence from a blank server message', async () => {
// `{"message": ""}` is not the server explaining itself. Treating it as one used to
// reach showToast('') and render an empty error toast.
const error = await failedSearch(() => Promise.resolve(jsonResponse({ message: '' }, 503)));
expect(describeSearchFailure(error)).toBe(UNREACHABLE_SOURCE_MESSAGE);
});
it('handles a non-Error rejection without inventing detail', () => {
expect(describeSearchFailure('something odd')).toBe(UNREACHABLE_SOURCE_MESSAGE);
});
});