diff --git a/CHANGELOG.md b/CHANGELOG.md index 982b7c6df..f013a0b7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Authentication settings: admin provisioning failures no longer silently let the auth-required toggle proceed.** `ConfigurationService.SaveApplicationSettingsAsync` previously caught any exception from `CreateUserAsync` / `UpdatePasswordAsync`, logged it, and returned successfully — so when admin credentials were supplied but the user-service rejected them (password policy violation, repo I/O error, concurrent-write race), `SettingsView.saveSettings()` would still go on to persist `AuthenticationRequired=true` on its second request. The result was an instance that required login but had no working admin account — exactly the lockout shape the credential-visibility fix below was meant to prevent. The catch now re-throws the failure so the caller aborts before the auth-toggle write. The settings row itself is still saved before the admin block (non-admin changes like notification triggers and webhooks shouldn't disappear because admin provisioning failed), and the no-credentials path remains an unchanged silent skip. - **Authentication settings: corrected misleading description on the "Enable login screen" toggle.** Previously said *"Changes here are local and will not modify server files — edit config/config.json on the host to persist"*, which was demonstrably wrong: `SettingsView` actually writes `authenticationRequired` back to the server's startup config on save. The description now accurately states the toggle persists, and prompts the user to set admin credentials in the same save. - **Authentication settings: admin credential fields are always visible.** Previously the *Admin Account Management* row was gated by `v-if="authEnabledComputed"` in `AuthenticationSection.vue`, which meant the only way to surface the username/password inputs was to first toggle on the login screen. If a user enabled `AuthenticationRequired` via `config.json` on the host (e.g., for the very first time) and then opened settings, the toggle reflected the server state (on), but if they instead opened settings *with auth still off*, the fields were hidden — and once they ticked the toggle and saved, the login screen activated immediately on the next page load, locking them out before they could create a user. The fields now render unconditionally so credentials can be configured before or after enabling auth. Help text and the password placeholder were updated to reflect the create-or-update semantics (blank password = keep existing). +- **`searchAudibleByTitleAndAuthor` silently dropped real matches:** The unified `POST /search` endpoint returns a flat array of Audible-shaped result objects (see `SearchController.cs`: `return Ok(flatMapped)`), but the FE service method typed the response as the wrapped `{ totalResults, results }` envelope and treated an array response as an object without `.results` — so every hit was discarded and callers saw "no matches" even when IntelligentSearch had found one. The method now normalizes both shapes. ## [0.2.71] - 2026-04-17 diff --git a/fe/src/__tests__/api.advancedSearch.spec.ts b/fe/src/__tests__/api.advancedSearch.spec.ts index 480541612..aeb9ac00e 100644 --- a/fe/src/__tests__/api.advancedSearch.spec.ts +++ b/fe/src/__tests__/api.advancedSearch.spec.ts @@ -86,4 +86,83 @@ describe('ApiService advancedSearch', () => { cap: 5, }) }) + + it('searchAudibleByTitleAndAuthor normalizes a bare-array response into the envelope shape', async () => { + // The backend POST /search endpoint returns a flat array of Audible-shaped + // results (Ok(flatMapped) in SearchController.cs). Earlier this method + // typed the response as `{ results: [] }` and silently dropped real hits + // when the array shape arrived. Make sure both shapes are handled. + vi.resetModules() + + const flatArray = [ + { asin: 'B009KS9JKA', title: 'A Darkness upon the Ice', authors: [{ name: 'W. Forstchen' }] }, + ] + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify(flatArray), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const actual = await vi.importActual('@/services/api') + const response = await actual.apiService.searchAudibleByTitleAndAuthor( + 'A Darkness Upon the Ice', + 'William R. Forstchen', + ) + + expect(response.totalResults).toBe(1) + expect(response.results).toHaveLength(1) + expect(response.results[0]?.asin).toBe('B009KS9JKA') + }) + + it('searchAudibleByTitleAndAuthor still accepts a wrapped { totalResults, results } response', async () => { + vi.resetModules() + + const wrapped = { + totalResults: 2, + results: [ + { asin: 'X1', title: 'One' }, + { asin: 'X2', title: 'Two' }, + ], + } + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(JSON.stringify(wrapped), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const actual = await vi.importActual('@/services/api') + const response = await actual.apiService.searchAudibleByTitleAndAuthor('One', 'Anyone') + + expect(response.totalResults).toBe(2) + expect(response.results).toHaveLength(2) + expect(response.results[1]?.asin).toBe('X2') + }) + + it('searchAudibleByTitleAndAuthor returns an empty envelope when the server responds with null', async () => { + vi.resetModules() + + const fetchMock = vi.fn(() => + Promise.resolve( + new Response('null', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const actual = await vi.importActual('@/services/api') + const response = await actual.apiService.searchAudibleByTitleAndAuthor('Whatever', 'Nobody') + + expect(response.totalResults).toBe(0) + expect(response.results).toEqual([]) + }) }) diff --git a/fe/src/services/api.ts b/fe/src/services/api.ts index df5f204ea..612b99621 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, @@ -388,13 +389,25 @@ class ApiService { region: string = 'us', language?: string, ): Promise { - // Use unified POST /search in Advanced mode to route author/title flows to Audible + // Use unified POST /search in Advanced mode to route author/title flows + // through IntelligentSearch (Audible + Audnexus + OpenLibrary). + // + // The endpoint returns a *flat* array of Audible-shaped result objects + // (see SearchController.cs: `return Ok(flatMapped)`), not the wrapped + // `{ totalResults, results }` envelope this method's return type + // implies. Normalize both shapes so a successful search isn't silently + // discarded by treating the array as an object without `.results`. const body: Record = { mode: 'Advanced', title, author, page, limit, region } if (language) (body as Record).language = language - const resp = await this.request('/search', { + const resp = await this.request< + AudibleSearchResult[] | AudibleSearchResponse | null + >('/search', { method: 'POST', body: JSON.stringify(body), }) + if (Array.isArray(resp)) { + return { totalResults: resp.length, results: resp } + } return resp ?? { totalResults: 0, results: [] } }