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(