feat(cover): let users pick the default provider for cover search

Cover search always opened on DuckDuckGo, so anyone who prefers another source had to switch it by
hand for every book. Store the choice per user and apply it when the drawer opens. Cache the value
client-side so the drawer never waits on a request to become usable, and save it to the account
rather than the device so it reaches the iOS client too. AudiobookCovers is not offered as a
default because it only returns results for audiobooks.

Closes #983
This commit is contained in:
neon
2026-08-11 20:02:49 +00:00
parent 37c3f3acd2
commit 7afc128bcb
13 changed files with 529 additions and 17 deletions
@@ -2,6 +2,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
const coverSearchPreferenceMock = await vi.hoisted(async () => {
const { ref } = await import('vue')
return {
defaultProvider: ref<'duckduckgo' | 'itunes' | 'all'>('duckduckgo'),
isLoading: ref(false),
load: vi.fn<() => Promise<boolean>>(),
}
})
vi.mock('@/features/book/composables/useCoverSearchPreferences', () => ({
useCoverSearchPreferences: () => coverSearchPreferenceMock,
}))
import CoverSearchDrawer from './CoverSearchDrawer.vue'
vi.mock('@/components/ui/sheet', () => ({
@@ -77,12 +90,76 @@ type DrawerVm = {
describe('CoverSearchDrawer', () => {
beforeEach(() => {
vi.clearAllMocks()
coverSearchPreferenceMock.defaultProvider.value = 'duckduckgo'
coverSearchPreferenceMock.isLoading.value = false
coverSearchPreferenceMock.load.mockResolvedValue(true)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('default provider preference', () => {
it('preloads the preference on mount so the provider is ready before the drawer opens', async () => {
coverSearchPreferenceMock.defaultProvider.value = 'itunes'
const wrapper = mountDrawer({ open: false })
await flushPromises()
expect(coverSearchPreferenceMock.load).toHaveBeenCalledOnce()
await wrapper.setProps({ open: true })
await flushPromises()
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('itunes')
})
it('leaves the search controls enabled while the preference loads', async () => {
const wrapper = mountDrawer({ open: false })
coverSearchPreferenceMock.load.mockReturnValueOnce(new Promise<boolean>(() => {}))
await wrapper.setProps({ open: true })
expect((wrapper.find('select').element as HTMLSelectElement).disabled).toBe(false)
expect(wrapper.findAll('button').every((button) => !(button.element as HTMLButtonElement).disabled)).toBe(true)
})
it('restores the saved provider instead of the previous manual choice when reopened', async () => {
coverSearchPreferenceMock.defaultProvider.value = 'itunes'
const wrapper = mountDrawer({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
await wrapper.find('select').setValue('all')
await wrapper.setProps({ open: false })
await wrapper.setProps({ open: true })
await flushPromises()
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('itunes')
})
it('keeps a manual provider choice made before a slow preference load resolves', async () => {
const wrapper = mountDrawer({ open: false })
await flushPromises()
let resolveLoad: (value: boolean) => void = () => {}
coverSearchPreferenceMock.load.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveLoad = resolve
}),
)
await wrapper.setProps({ open: true })
await wrapper.find('select').setValue('all')
coverSearchPreferenceMock.defaultProvider.value = 'itunes'
resolveLoad(true)
await flushPromises()
expect((wrapper.find('select').element as HTMLSelectElement).value).toBe('all')
})
})
describe('audiobook checkbox', () => {
it('defaults checkbox to false for regular books', () => {
const wrapper = mountDrawer({ isAudiobook: false })
@@ -281,6 +358,7 @@ describe('CoverSearchDrawer', () => {
})
const wrapper = mountDrawer()
await flushPromises()
await wrapper.find('select').setValue('itunes')
await (wrapper.vm as unknown as DrawerVm).performSearch()
await flushPromises()
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Search, Loader2, Image as ImageIcon, Check } from '@lucide/vue'
import type { CoverSearchResult } from '@bookorbit/types'
import type { CoverSearchDefaultProvider, CoverSearchResult } from '@bookorbit/types'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { useCoverSearchPreferences } from '@/features/book/composables/useCoverSearchPreferences'
const props = defineProps<{
open: boolean
@@ -13,6 +14,7 @@ const props = defineProps<{
}>()
const { t } = useI18n()
const { defaultProvider, load: loadCoverSearchPreferences } = useCoverSearchPreferences()
const emit = defineEmits<{
'update:open': [boolean]
@@ -21,31 +23,45 @@ const emit = defineEmits<{
const searchTitle = ref(props.initialTitle)
const searchAuthor = ref(props.initialAuthor)
const searchProvider = ref<'duckduckgo' | 'itunes' | 'audiobookcovers' | 'all'>('duckduckgo')
type SearchProvider = CoverSearchDefaultProvider | 'audiobookcovers'
const searchProvider = ref<SearchProvider>(defaultProvider.value)
const isAudiobookSearch = ref(props.isAudiobook)
const isSearching = ref(false)
const searchResults = ref<CoverSearchResult[]>([])
const hasSearched = ref(false)
let openSequence = 0
const resultAspectClass = computed(() => (isAudiobookSearch.value ? 'aspect-square' : 'aspect-[2/3]'))
onMounted(() => {
void loadCoverSearchPreferences()
})
watch(
() => props.open,
(val) => {
if (val) {
searchTitle.value = props.initialTitle
searchAuthor.value = props.initialAuthor
isAudiobookSearch.value = props.isAudiobook
searchResults.value = []
hasSearched.value = false
}
async (val) => {
const sequence = ++openSequence
if (!val) return
searchTitle.value = props.initialTitle
searchAuthor.value = props.initialAuthor
isAudiobookSearch.value = props.isAudiobook
searchResults.value = []
hasSearched.value = false
const openedWith = defaultProvider.value
searchProvider.value = openedWith
await loadCoverSearchPreferences()
if (sequence === openSequence && searchProvider.value === openedWith) searchProvider.value = defaultProvider.value
},
)
function toggleAudiobookSearch() {
isAudiobookSearch.value = !isAudiobookSearch.value
if (!isAudiobookSearch.value && searchProvider.value === 'audiobookcovers') {
searchProvider.value = 'duckduckgo'
searchProvider.value = defaultProvider.value
}
}
@@ -140,6 +156,7 @@ function handleOpenChange(val: boolean) {
</div>
<button
class="md:hidden flex items-center justify-center h-9 aspect-square rounded-lg bg-primary text-primary-foreground transition-all shadow-sm active:scale-[0.98] disabled:opacity-50 shrink-0"
:aria-label="t('book.detail.coverSearch.findCovers')"
:disabled="isSearching"
@click="performSearch"
>
@@ -0,0 +1,123 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const apiMock = vi.hoisted(() => vi.fn<(...args: [RequestInfo | URL, RequestInit?]) => Promise<unknown>>())
vi.mock('@/lib/api', () => ({ api: apiMock }))
function response(body: unknown, ok = true): Pick<Response, 'json' | 'ok'> {
return {
ok,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
}
}
async function freshComposable() {
vi.resetModules()
const { useCoverSearchPreferences } = await import('../useCoverSearchPreferences')
return useCoverSearchPreferences()
}
describe('useCoverSearchPreferences', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('loads the saved default provider', async () => {
apiMock.mockResolvedValueOnce(response({ settings: { defaultProvider: 'itunes' } }))
const preferences = await freshComposable()
await expect(preferences.load()).resolves.toBe(true)
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/cover-search')
expect(preferences.defaultProvider.value).toBe('itunes')
})
it('falls back to DuckDuckGo when the response is malformed', async () => {
apiMock.mockResolvedValueOnce(response({ settings: { defaultProvider: 'unknown' } }))
const preferences = await freshComposable()
await preferences.load()
expect(preferences.defaultProvider.value).toBe('duckduckgo')
})
it('fetches once and serves later loads from cache', async () => {
apiMock.mockResolvedValueOnce(response({ settings: { defaultProvider: 'itunes' } }))
const preferences = await freshComposable()
await preferences.load()
await expect(preferences.load()).resolves.toBe(true)
expect(apiMock).toHaveBeenCalledOnce()
})
it('shares a single request between concurrent loads', async () => {
apiMock.mockResolvedValueOnce(response({ settings: { defaultProvider: 'all' } }))
const preferences = await freshComposable()
await Promise.all([preferences.load(), preferences.load(), preferences.load()])
expect(apiMock).toHaveBeenCalledOnce()
expect(preferences.defaultProvider.value).toBe('all')
})
it('retries after a failed load instead of caching the failure', async () => {
apiMock.mockRejectedValueOnce(new Error('offline'))
const preferences = await freshComposable()
await expect(preferences.load()).resolves.toBe(false)
apiMock.mockResolvedValueOnce(response({ settings: { defaultProvider: 'itunes' } }))
await expect(preferences.load()).resolves.toBe(true)
expect(apiMock).toHaveBeenCalledTimes(2)
expect(preferences.defaultProvider.value).toBe('itunes')
})
it('persists a new default provider', async () => {
apiMock.mockResolvedValueOnce(response(undefined))
const preferences = await freshComposable()
await expect(preferences.update('all')).resolves.toBe(true)
expect(preferences.defaultProvider.value).toBe('all')
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/cover-search', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { defaultProvider: 'all' } }),
})
})
it('treats a saved provider as loaded so no fetch follows', async () => {
apiMock.mockResolvedValueOnce(response(undefined))
const preferences = await freshComposable()
await preferences.update('itunes')
await preferences.load()
expect(apiMock).toHaveBeenCalledOnce()
expect(preferences.defaultProvider.value).toBe('itunes')
})
it('restores the previous provider when saving fails', async () => {
apiMock.mockResolvedValueOnce(response(undefined, false))
const preferences = await freshComposable()
preferences.defaultProvider.value = 'itunes'
await expect(preferences.update('all')).resolves.toBe(false)
expect(preferences.defaultProvider.value).toBe('itunes')
expect(preferences.isSaving.value).toBe(false)
})
it('keeps the current default when loading fails', async () => {
apiMock.mockRejectedValueOnce(new Error('offline'))
const preferences = await freshComposable()
preferences.defaultProvider.value = 'itunes'
await expect(preferences.load()).resolves.toBe(false)
expect(preferences.defaultProvider.value).toBe('itunes')
expect(preferences.isLoading.value).toBe(false)
})
})
@@ -0,0 +1,77 @@
import {
COVER_SEARCH_DEFAULT_PROVIDERS,
DEFAULT_COVER_SEARCH_PROVIDER,
type CoverSearchDefaultProvider,
type CoverSearchPreferences,
} from '@bookorbit/types'
import { ref } from 'vue'
import { api } from '@/lib/api'
const ENDPOINT = '/api/v1/user-preferences/cover-search'
const defaultProvider = ref<CoverSearchDefaultProvider>(DEFAULT_COVER_SEARCH_PROVIDER)
const isLoading = ref(false)
const isSaving = ref(false)
let hasLoaded = false
let inFlightLoad: Promise<boolean> | null = null
function normalizeProvider(value: unknown): CoverSearchDefaultProvider {
return typeof value === 'string' && COVER_SEARCH_DEFAULT_PROVIDERS.includes(value as CoverSearchDefaultProvider)
? (value as CoverSearchDefaultProvider)
: DEFAULT_COVER_SEARCH_PROVIDER
}
async function fetchPreferences(): Promise<boolean> {
isLoading.value = true
try {
const response = await api(ENDPOINT)
if (!response.ok) return false
const body = (await response.json()) as { settings?: Partial<CoverSearchPreferences> }
defaultProvider.value = normalizeProvider(body.settings?.defaultProvider)
hasLoaded = true
return true
} catch {
return false
} finally {
isLoading.value = false
}
}
export function useCoverSearchPreferences() {
async function load(): Promise<boolean> {
if (hasLoaded) return true
inFlightLoad ??= fetchPreferences().finally(() => {
inFlightLoad = null
})
return inFlightLoad
}
async function update(provider: CoverSearchDefaultProvider): Promise<boolean> {
const previous = defaultProvider.value
defaultProvider.value = provider
isSaving.value = true
try {
const response = await api(ENDPOINT, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { defaultProvider: provider } } satisfies { settings: CoverSearchPreferences }),
})
if (!response.ok) {
defaultProvider.value = previous
return false
}
hasLoaded = true
return true
} catch {
defaultProvider.value = previous
return false
} finally {
isSaving.value = false
}
}
return { defaultProvider, isLoading, isSaving, load, update }
}
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { Image } from '@lucide/vue'
import { COVER_SEARCH_DEFAULT_PROVIDERS, type CoverSearchDefaultProvider } from '@bookorbit/types'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import {
useDisplaySettings,
@@ -11,10 +13,24 @@ import {
type BookSpineOverlay,
type CardOverlayKey,
} from '@/composables/useDisplaySettings'
import { useCoverSearchPreferences } from '@/features/book/composables/useCoverSearchPreferences'
const { t } = useI18n()
const { cardOverlays, bookSpineOverlay, showSpineOnComics, bookShadowStrength, bookCoverDisplayMode, bookDetailCoverTint } = useDisplaySettings()
const {
defaultProvider: defaultCoverSearchProvider,
isLoading: isCoverSearchPreferenceLoading,
isSaving: isCoverSearchPreferenceSaving,
load: loadCoverSearchPreferences,
update: updateCoverSearchPreferences,
} = useCoverSearchPreferences()
const coverSearchProviderOptions = computed<{ id: CoverSearchDefaultProvider; label: string }[]>(() => [
{ id: 'duckduckgo', label: 'DuckDuckGo' },
{ id: 'itunes', label: 'iTunes' },
{ id: 'all', label: t('book.detail.coverSearch.allSources') },
])
const overlayOptions = computed<{ key: CardOverlayKey; label: string; hint: string }[]>(() => [
{
@@ -137,6 +153,19 @@ function setBookCoverDisplayMode(mode: BookCoverDisplayMode) {
function setBookDetailCoverTint(mode: BookDetailCoverTint) {
bookDetailCoverTint.value = mode
}
async function handleDefaultCoverSearchProviderChange(event: Event) {
const value = (event.currentTarget as HTMLSelectElement).value
if (!COVER_SEARCH_DEFAULT_PROVIDERS.includes(value as CoverSearchDefaultProvider)) return
const saved = await updateCoverSearchPreferences(value as CoverSearchDefaultProvider)
if (!saved) toast.error(t('settings.appearance.bookCovers.coverSearch.saveError'))
}
onMounted(async () => {
const loaded = await loadCoverSearchPreferences()
if (!loaded) toast.error(t('settings.appearance.bookCovers.coverSearch.loadError'))
})
</script>
<template>
@@ -145,6 +174,27 @@ function setBookDetailCoverTint(mode: BookDetailCoverTint) {
{{ t('settings.appearance.bookCovers.title') }}
</p>
<div class="border border-border rounded-lg overflow-hidden divide-y divide-border mb-4 shadow-xs">
<div class="settings-row">
<div>
<label for="default-cover-search-provider" class="settings-label">
{{ t('settings.appearance.bookCovers.coverSearch.label') }}
</label>
<p class="settings-hint">
{{ t('settings.appearance.bookCovers.coverSearch.hint') }}
</p>
</div>
<select
id="default-cover-search-provider"
:value="defaultCoverSearchProvider"
class="h-9 min-w-40 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
:disabled="isCoverSearchPreferenceLoading || isCoverSearchPreferenceSaving"
@change="handleDefaultCoverSearchProviderChange"
>
<option v-for="provider in coverSearchProviderOptions" :key="provider.id" :value="provider.id">
{{ provider.label }}
</option>
</select>
</div>
<div class="px-4 py-3.5 md:px-5 md:py-4 bg-card">
<div>
<p class="settings-label">
@@ -1,5 +1,21 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const coverSearchPreferenceMock = await vi.hoisted(async () => {
const { ref } = await import('vue')
return {
defaultProvider: ref<'duckduckgo' | 'itunes' | 'all'>('duckduckgo'),
isLoading: ref(false),
isSaving: ref(false),
load: vi.fn<() => Promise<boolean>>(),
update: vi.fn<(provider: string) => Promise<boolean>>(),
}
})
vi.mock('@/features/book/composables/useCoverSearchPreferences', () => ({
useCoverSearchPreferences: () => coverSearchPreferenceMock,
}))
import AppearanceBookCoverSettings from '../AppearanceBookCoverSettings.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
@@ -20,12 +36,31 @@ function mountSettings() {
})
}
beforeEach(() => {
vi.clearAllMocks()
coverSearchPreferenceMock.defaultProvider.value = 'duckduckgo'
coverSearchPreferenceMock.isLoading.value = false
coverSearchPreferenceMock.isSaving.value = false
coverSearchPreferenceMock.load.mockResolvedValue(true)
coverSearchPreferenceMock.update.mockResolvedValue(true)
})
afterEach(() => {
showSpineOnComics.value = false
bookDetailCoverTint.value = 'single'
})
describe('AppearanceBookCoverSettings', () => {
it('loads and saves the default cover search provider', async () => {
const wrapper = mountSettings()
expect(coverSearchPreferenceMock.load).toHaveBeenCalledOnce()
await wrapper.find('#default-cover-search-provider').setValue('itunes')
expect(coverSearchPreferenceMock.update).toHaveBeenCalledWith('itunes')
})
it('reflects and toggles showSpineOnComics from the dedicated switch', async () => {
const wrapper = mountSettings()
+6
View File
@@ -218,6 +218,12 @@
},
"bookCovers": {
"title": "Book Covers",
"coverSearch": {
"label": "Default cover search provider",
"hint": "Choose the source selected when Find cover online opens. Saved to your account, so it applies on every device.",
"loadError": "Failed to load the default cover search provider",
"saveError": "Failed to save the default cover search provider"
},
"displayMode": {
"title": "Cover display mode",
"hint": "Controls how real covers fit inside book slots when aspect ratios differ",
@@ -0,0 +1,9 @@
export const COVER_SEARCH_DEFAULT_PROVIDERS = ["duckduckgo", "itunes", "all"] as const;
export type CoverSearchDefaultProvider = (typeof COVER_SEARCH_DEFAULT_PROVIDERS)[number];
export const DEFAULT_COVER_SEARCH_PROVIDER: CoverSearchDefaultProvider = "duckduckgo";
export interface CoverSearchPreferences {
defaultProvider: CoverSearchDefaultProvider;
}
+1
View File
@@ -58,3 +58,4 @@ export * from "./readwise";
export * from "./storygraph";
export * from "./path";
export * from "./sidebar";
export * from "./cover-search-preferences";
@@ -1,5 +1,11 @@
import type { Mocked } from 'vitest';
import { EMPTY_CONTENT_FILTER_RULES, type DisplayPreferences, type LocalePreferences, type ThemePreferences } from '@bookorbit/types';
import {
EMPTY_CONTENT_FILTER_RULES,
type CoverSearchPreferences,
type DisplayPreferences,
type LocalePreferences,
type ThemePreferences,
} from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { UserPreferencesController } from './user-preferences.controller';
@@ -61,6 +67,10 @@ const validLocalePreferences: LocalePreferences = {
locale: 'nl',
};
const validCoverSearchPreferences: CoverSearchPreferences = {
defaultProvider: 'itunes',
};
describe('UserPreferencesController', () => {
let service: Mocked<UserPreferencesService>;
let controller: UserPreferencesController;
@@ -70,9 +80,11 @@ describe('UserPreferencesController', () => {
getThemePreferences: vi.fn(),
getDisplayPreferences: vi.fn(),
getLocalePreferences: vi.fn(),
getCoverSearchPreferences: vi.fn(),
upsertThemePreferences: vi.fn(),
upsertDisplayPreferences: vi.fn(),
upsertLocalePreferences: vi.fn(),
upsertCoverSearchPreferences: vi.fn(),
} as unknown as Mocked<UserPreferencesService>;
controller = new UserPreferencesController(service);
@@ -136,6 +148,20 @@ describe('UserPreferencesController', () => {
expect(service.upsertDisplayPreferences).toHaveBeenCalledWith(99, validDisplayPreferences);
});
it('GET /cover-search returns the current user preference', async () => {
service.getCoverSearchPreferences.mockResolvedValueOnce(validCoverSearchPreferences);
await expect(controller.getCoverSearchPreferences(makeUser({ id: 11 }))).resolves.toEqual({ settings: validCoverSearchPreferences });
expect(service.getCoverSearchPreferences).toHaveBeenCalledWith(11);
});
it('PUT /cover-search forwards the current user id and settings', async () => {
const dto = { settings: validCoverSearchPreferences as unknown as Record<string, unknown> };
await expect(controller.upsertCoverSearchPreferences(dto, makeUser({ id: 42 }))).resolves.toBeUndefined();
expect(service.upsertCoverSearchPreferences).toHaveBeenCalledWith(42, validCoverSearchPreferences);
});
it('GET /locale returns null settings when no saved preferences exist', async () => {
service.getLocalePreferences.mockResolvedValueOnce(null);
@@ -33,6 +33,18 @@ export class UserPreferencesController {
await this.userPreferencesService.upsertDisplayPreferences(user.id, dto.settings);
}
@Get('cover-search')
async getCoverSearchPreferences(@CurrentUser() user: RequestUser) {
const settings = await this.userPreferencesService.getCoverSearchPreferences(user.id);
return { settings };
}
@Put('cover-search')
@HttpCode(204)
async upsertCoverSearchPreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
await this.userPreferencesService.upsertCoverSearchPreferences(user.id, dto.settings);
}
@Get('locale')
async getLocalePreferences(@CurrentUser() user: RequestUser) {
const settings = await this.userPreferencesService.getLocalePreferences(user.id);
@@ -1,6 +1,13 @@
import { BadRequestException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Accent, DisplayPreferences, LocalePreferences, ServerFontPreferences, ThemePreferences } from '@bookorbit/types';
import type {
Accent,
CoverSearchPreferences,
DisplayPreferences,
LocalePreferences,
ServerFontPreferences,
ThemePreferences,
} from '@bookorbit/types';
import { MAX_SERVER_FONTS, SUPPORTED_LOCALES } from '@bookorbit/types';
import { UserPreferencesRepository } from './user-preferences.repository';
@@ -82,10 +89,16 @@ const validLocalePreferences: LocalePreferences = {
locale: 'it',
};
const validCoverSearchPreferences: CoverSearchPreferences = {
defaultProvider: 'itunes',
};
const repo = {
findByCategory:
vi.fn<
(...args: [number, string]) => Promise<{ data: ThemePreferences | DisplayPreferences | LocalePreferences | ServerFontPreferences } | undefined>
(
...args: [number, string]
) => Promise<{ data: ThemePreferences | DisplayPreferences | LocalePreferences | ServerFontPreferences | CoverSearchPreferences } | undefined>
>(),
upsert: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
delete: vi.fn<(...args: [number, string]) => Promise<void>>(),
@@ -123,6 +136,40 @@ describe('UserPreferencesService', () => {
await expect(service.getDisplayPreferences(7)).resolves.toEqual(validDisplayPreferences);
});
it('getCoverSearchPreferences returns the DuckDuckGo default when no preference exists', async () => {
await expect(service.getCoverSearchPreferences(7)).resolves.toEqual({ defaultProvider: 'duckduckgo' });
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'cover-search');
});
it('getCoverSearchPreferences returns a saved provider', async () => {
repo.findByCategory.mockResolvedValueOnce({ data: validCoverSearchPreferences });
await expect(service.getCoverSearchPreferences(7)).resolves.toEqual(validCoverSearchPreferences);
});
it('getCoverSearchPreferences falls back safely when stored data is malformed', async () => {
repo.findByCategory.mockResolvedValueOnce({ data: { defaultProvider: 'unknown' } } as never);
await expect(service.getCoverSearchPreferences(7)).resolves.toEqual({ defaultProvider: 'duckduckgo' });
});
it.each(['duckduckgo', 'itunes', 'all'] as const)('upsertCoverSearchPreferences accepts %s', async (defaultProvider) => {
await expect(service.upsertCoverSearchPreferences(11, { defaultProvider })).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'cover-search', { defaultProvider });
});
it('upsertCoverSearchPreferences rejects a provider that is not valid for every book', async () => {
await expect(service.upsertCoverSearchPreferences(11, { defaultProvider: 'audiobookcovers' })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertCoverSearchPreferences rejects unknown fields', async () => {
await expect(service.upsertCoverSearchPreferences(11, { ...validCoverSearchPreferences, unexpected: true })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('getLocalePreferences returns null when repository has no row', async () => {
await expect(service.getLocalePreferences(7)).resolves.toBeNull();
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'locale');
@@ -10,7 +10,9 @@ import {
BOOK_VIEW_MODES,
CARD_INFO_MODES,
CARD_OVERLAY_KEYS,
COVER_SEARCH_DEFAULT_PROVIDERS,
COVER_SIZE_SCOPES,
DEFAULT_COVER_SEARCH_PROVIDER,
FONT_FAMILY_NAME_MAX_LENGTH,
GRID_CARD_LABEL_FIELDS,
MAX_SERVER_FONTS,
@@ -22,6 +24,7 @@ import {
TABLE_DENSITIES,
THEME_IDS,
type DisplayPreferences,
type CoverSearchPreferences,
type LocalePreferences,
type ServerFontPreferences,
type ThemePreferences,
@@ -82,6 +85,16 @@ const DISPLAY_PREFERENCES_SCHEMA = z
}
});
const COVER_SEARCH_PREFERENCES_SCHEMA = z
.object({
defaultProvider: z.enum(COVER_SEARCH_DEFAULT_PROVIDERS),
})
.strict();
const COVER_SEARCH_DEFAULTS: CoverSearchPreferences = {
defaultProvider: DEFAULT_COVER_SEARCH_PROVIDER,
};
const LOCALE_PREFERENCES_SCHEMA = z
.object({
locale: z.enum(SUPPORTED_LOCALES),
@@ -227,6 +240,24 @@ export class UserPreferencesService {
return row ? (row.data as DisplayPreferences) : null;
}
async getCoverSearchPreferences(userId: number): Promise<CoverSearchPreferences> {
const row = await this.repo.findByCategory(userId, 'cover-search');
const result = COVER_SEARCH_PREFERENCES_SCHEMA.safeParse(row?.data);
return result.success ? result.data : { ...COVER_SEARCH_DEFAULTS };
}
async upsertCoverSearchPreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const result = COVER_SEARCH_PREFERENCES_SCHEMA.safeParse(data);
if (!result.success) {
const firstIssue = result.error.issues[0];
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
throw new BadRequestException(`Invalid cover search preferences at "${issuePath}": ${issueMessage}`);
}
await this.repo.upsert(userId, 'cover-search', result.data);
}
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const start = Date.now();
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);