From 5c35e091896a5d9ba5b4847e88dafc89f6285ad7 Mon Sep 17 00:00:00 2001 From: Kevin Heneveld <1192102+kevinheneveld@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:11:35 -0800 Subject: [PATCH] fix(search): searchAudibleByTitleAndAuthor discarded every result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Advanced-mode branch of POST /search returns a bare JSON array, not {results, totalResults} — confirmed live against a real Audible title/author search: the backend genuinely found the book, but the UI still reported "no matches in any region", because this function only ever read response.results, which doesn't exist on a plain array. The other two frontend callers of this same endpoint (getAuthorLookup, advancedSearch) already defensively handle both response shapes; this one never did, so every search through it silently discarded whatever the backend found and always fell through to the empty-results path, regardless of query quality. --- .../api.searchAudibleByTitleAndAuthor.spec.ts | 85 +++++++++++++++++++ fe/src/services/api.ts | 17 ++-- 2 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 fe/src/__tests__/api.searchAudibleByTitleAndAuthor.spec.ts diff --git a/fe/src/__tests__/api.searchAudibleByTitleAndAuthor.spec.ts b/fe/src/__tests__/api.searchAudibleByTitleAndAuthor.spec.ts new file mode 100644 index 000000000..2ba25f240 --- /dev/null +++ b/fe/src/__tests__/api.searchAudibleByTitleAndAuthor.spec.ts @@ -0,0 +1,85 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { describe, it, expect, vi, afterEach } from 'vitest' + +describe('ApiService searchAudibleByTitleAndAuthor', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('unwraps a bare JSON array response (the shape /search Advanced mode actually returns)', async () => { + // Live false-flag: the "find correct match" modal reported "No matches in + // any region" for a book Audible genuinely had, in every region, every + // time — because this function only read response.results, and the + // backend's Advanced-search branch returns a bare array, not + // {results, totalResults}. Every other caller of this same endpoint + // (getAuthorLookup, advancedSearch) already handled both shapes. + vi.resetModules() + + const candidate = { asin: 'B002VA9GXE', title: 'Dark Lover', region: 'us' } + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify([candidate]), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const actual = await vi.importActual('@/services/api') + const response = await actual.apiService.searchAudibleByTitleAndAuthor( + 'Dark Lover', + 'J.R. Ward', + 1, + 25, + 'us', + ) + + expect(response.totalResults).toBe(1) + expect(response.results).toEqual([candidate]) + }) + + it('still works if the backend wraps results in an object', async () => { + vi.resetModules() + + const candidate = { asin: 'B002VA9GXE', title: 'Dark Lover', region: 'us' } + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify({ totalResults: 1, results: [candidate] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const actual = await vi.importActual('@/services/api') + const response = await actual.apiService.searchAudibleByTitleAndAuthor( + 'Dark Lover', + 'J.R. Ward', + 1, + 25, + 'us', + ) + + expect(response.totalResults).toBe(1) + expect(response.results).toEqual([candidate]) + }) +}) diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index 91c699d90..6d120e894 100644 --- a/fe/src/services/api.ts +++ b/fe/src/services/api.ts @@ -39,6 +39,7 @@ import type { SearchSortBy, SearchSortDirection, AudibleSearchResponse, + AudibleSearchResult, AudibleBookMetadata, AuthorCatalogResponse, AuthorLookupResponse, @@ -391,11 +392,17 @@ class ApiService { // Use unified POST /search in Advanced mode to route author/title flows to Audible const body: Record = { mode: 'Advanced', title, author, page, limit, region } if (language) (body as Record).language = language - const resp = await this.request('/search', { - method: 'POST', - body: JSON.stringify(body), - }) - return resp ?? { totalResults: 0, results: [] } + const resp = await this.request( + '/search', + { method: 'POST', body: JSON.stringify(body) }, + ) + // The backend's Advanced-search branch returns a bare JSON array, not + // {results, totalResults} — every other caller of this same endpoint + // (getAuthorLookup, advancedSearch) already handles both shapes; this one + // didn't, so `resp.results` was always undefined and every search here + // silently came back empty regardless of what the backend actually found. + const results = Array.isArray(resp) ? resp : (resp?.results ?? []) + return { totalResults: results.length, results } } async getAuthorLookup(