mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user