Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
79 changes: 79 additions & 0 deletions fe/src/__tests__/api.advancedSearch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@/services/api')>('@/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<typeof import('@/services/api')>('@/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<typeof import('@/services/api')>('@/services/api')
const response = await actual.apiService.searchAudibleByTitleAndAuthor('Whatever', 'Nobody')

expect(response.totalResults).toBe(0)
expect(response.results).toEqual([])
})
})
17 changes: 15 additions & 2 deletions fe/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
SearchSortBy,
SearchSortDirection,
AudibleSearchResponse,
AudibleSearchResult,
AudibleBookMetadata,
AuthorCatalogResponse,
AuthorLookupResponse,
Expand Down Expand Up @@ -388,13 +389,25 @@ class ApiService {
region: string = 'us',
language?: string,
): Promise<AudibleSearchResponse> {
// 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<string, unknown> = { mode: 'Advanced', title, author, page, limit, region }
if (language) (body as Record<string, unknown>).language = language
const resp = await this.request<AudibleSearchResponse | null>('/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: [] }
}

Expand Down