mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(metadata): persist auto-fetch library overrides
Ensure the settings UI saves inherit toggles immediately and guards eligible-count previews against stale async responses. Align library override DTO validation with the shared partial override contract, and record manual library runs even when nothing queues.
This commit is contained in:
@@ -31,7 +31,7 @@ export function useBookMetadataFetchConfig() {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise<BookMetadataFetchConfig> {
|
||||
async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise<BookMetadataFetchLibraryConfig> {
|
||||
const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { effectScope, nextTick, ref, type EffectScope } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookMetadataFetchConditions } from '@bookorbit/types'
|
||||
import { invalidateEligibleCountPreviews, useEligibleCountPreview } from './useEligibleCountPreview'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args),
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function response(count: number) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ count }),
|
||||
}
|
||||
}
|
||||
|
||||
function makeConditions(threshold: number): BookMetadataFetchConditions {
|
||||
return {
|
||||
neverFetched: { enabled: false },
|
||||
scoreThreshold: { enabled: true, threshold },
|
||||
missingFields: { enabled: false, fields: [] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEligibleCountPreview', () => {
|
||||
let scope: EffectScope
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
apiMock.mockReset()
|
||||
scope = effectScope()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('ignores stale in-flight count responses after conditions change', async () => {
|
||||
const first = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
const second = deferred<{ ok: boolean; json: () => Promise<unknown> }>()
|
||||
apiMock.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise)
|
||||
|
||||
const conditions = ref(makeConditions(70))
|
||||
const preview = scope.run(() => useEligibleCountPreview(conditions))!
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
expect(apiMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
conditions.value = makeConditions(45)
|
||||
await nextTick()
|
||||
first.resolve(response(12))
|
||||
await flushPromises()
|
||||
|
||||
expect(preview.count.value).toBeNull()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
second.resolve(response(8))
|
||||
await flushPromises()
|
||||
|
||||
expect(preview.count.value).toBe(8)
|
||||
})
|
||||
|
||||
it('refreshes all previews when invalidated', async () => {
|
||||
apiMock.mockResolvedValue(response(3))
|
||||
const conditions = ref(makeConditions(70))
|
||||
|
||||
scope.run(() => useEligibleCountPreview(conditions, 5))
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
await flushPromises()
|
||||
|
||||
invalidateEligibleCountPreviews()
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledTimes(2)
|
||||
const secondCall = apiMock.mock.calls[1]
|
||||
expect(secondCall).toBeDefined()
|
||||
const [, init] = secondCall as [string, RequestInit]
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
libraryId: 5,
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 70 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('clears count without fetching when conditions are unavailable', async () => {
|
||||
const conditions = ref<BookMetadataFetchConditions | null>(null)
|
||||
const preview = scope.run(() => useEligibleCountPreview(conditions))!
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
expect(preview.count.value).toBeNull()
|
||||
expect(preview.loading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,25 @@
|
||||
import { ref, watch, type Ref } from 'vue'
|
||||
import { onScopeDispose, ref, watch, type Ref } from 'vue'
|
||||
import type { BookMetadataFetchConditions } from '@bookorbit/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
const refreshToken = ref(0)
|
||||
|
||||
export function invalidateEligibleCountPreviews() {
|
||||
refreshToken.value += 1
|
||||
}
|
||||
|
||||
export function useEligibleCountPreview(conditions: Ref<BookMetadataFetchConditions | null>, libraryId?: number) {
|
||||
const count = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requestId = 0
|
||||
|
||||
async function fetchCount() {
|
||||
const currentRequestId = ++requestId
|
||||
const current = conditions.value
|
||||
if (!current) {
|
||||
count.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
@@ -24,21 +33,28 @@ export function useEligibleCountPreview(conditions: Ref<BookMetadataFetchConditi
|
||||
})
|
||||
if (res.ok) {
|
||||
const data: { count: number } = await res.json()
|
||||
if (currentRequestId !== requestId) return
|
||||
count.value = data.count
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (currentRequestId === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
conditions,
|
||||
[conditions, refreshToken],
|
||||
() => {
|
||||
requestId += 1
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(fetchCount, 400)
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
requestId += 1
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
return { count, loading }
|
||||
}
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import ConditionConfigurator from './ConditionConfigurator.vue'
|
||||
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
|
||||
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
|
||||
import { useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
|
||||
import { invalidateEligibleCountPreviews, useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
const { saveGlobalConfig } = useBookMetadataFetchConfig()
|
||||
@@ -65,6 +65,7 @@ async function handleTrigger() {
|
||||
try {
|
||||
const { queued } = await triggerGlobal()
|
||||
triggerResult.value = queued > 0 ? `Queued ${queued} books` : 'No eligible books found'
|
||||
invalidateEligibleCountPreviews()
|
||||
} finally {
|
||||
triggering.value = false
|
||||
}
|
||||
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BookMetadataFetchConfig, BookMetadataFetchLibraryConfig, Library } from '@bookorbit/types'
|
||||
import LibraryAutoFetchConfig from './LibraryAutoFetchConfig.vue'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>())
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: (...args: unknown[]) => apiMock(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useMediaQuery: () => ref(false),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/ToggleSwitch.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<button type="button" data-testid="toggle-switch" @click="$emit(\'update:modelValue\', !modelValue)" />',
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./ConditionConfigurator.vue', () => ({
|
||||
default: {
|
||||
props: ['modelValue', 'disabled'],
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-testid="condition-configurator" :data-disabled="String(disabled)" />',
|
||||
},
|
||||
}))
|
||||
|
||||
function response(data: unknown) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => data,
|
||||
}
|
||||
}
|
||||
|
||||
function makeConfig(threshold: number): BookMetadataFetchConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
triggerOnImport: false,
|
||||
conditions: {
|
||||
neverFetched: { enabled: false },
|
||||
scoreThreshold: { enabled: true, threshold },
|
||||
missingFields: { enabled: false, fields: [] },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeLibraryConfig(): BookMetadataFetchLibraryConfig {
|
||||
return {
|
||||
...makeConfig(45),
|
||||
override: {
|
||||
enabled: true,
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 45 },
|
||||
},
|
||||
},
|
||||
lastRunAt: null,
|
||||
lastQueuedCount: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeInheritedLibraryConfig(): BookMetadataFetchLibraryConfig {
|
||||
return {
|
||||
...makeConfig(70),
|
||||
override: null,
|
||||
lastRunAt: null,
|
||||
lastQueuedCount: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRunHistoryConfig(lastRunAt: string, lastQueuedCount: number | null): BookMetadataFetchLibraryConfig {
|
||||
return {
|
||||
...makeInheritedLibraryConfig(),
|
||||
lastRunAt,
|
||||
lastQueuedCount,
|
||||
}
|
||||
}
|
||||
|
||||
function makeLibrary(): Library {
|
||||
return {
|
||||
id: 2,
|
||||
name: 'PDFs',
|
||||
icon: null,
|
||||
displayOrder: 0,
|
||||
coverAspectRatio: '2/3',
|
||||
watch: false,
|
||||
autoScanCronExpression: null,
|
||||
metadataPrecedence: [],
|
||||
formatPriority: [],
|
||||
allowedFormats: [],
|
||||
organizationMode: 'book_per_file',
|
||||
excludePatterns: [],
|
||||
readingThreshold: 10,
|
||||
markAsFinishedPercentComplete: 90,
|
||||
fileNamingPattern: null,
|
||||
fileWriteEnabled: false,
|
||||
fileWriteWriteCover: false,
|
||||
fileWriteEpubEnabled: false,
|
||||
fileWriteEpubMaxFileSizeMb: 100,
|
||||
fileWritePdfEnabled: false,
|
||||
fileWritePdfMaxFileSizeMb: 100,
|
||||
fileWriteCbxEnabled: false,
|
||||
fileWriteCbxMaxFileSizeMb: 500,
|
||||
fileWriteAudioEnabled: false,
|
||||
fileWriteAudioMaxFileSizeMb: 500,
|
||||
fileRenameEnabled: false,
|
||||
folders: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent() {
|
||||
return mount(LibraryAutoFetchConfig, {
|
||||
props: {
|
||||
library: makeLibrary(),
|
||||
globalConfig: makeConfig(70),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('LibraryAutoFetchConfig', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
apiMock.mockReset()
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeLibraryConfig())
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit).method === 'PUT') return response(makeLibraryConfig())
|
||||
return response({})
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('renders stored overrides instead of assuming global inheritance', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(401)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Inheriting global defaults')
|
||||
expect(wrapper.text()).toContain('Score < 45')
|
||||
expect(wrapper.text()).toContain('Save override')
|
||||
|
||||
const previewCall = apiMock.mock.calls.find(([url]) => String(url) === '/api/v1/book-metadata-fetch/preview-count')
|
||||
expect(previewCall).toBeDefined()
|
||||
const [, init] = previewCall as [string, RequestInit]
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
libraryId: 2,
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 45 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('saves only config fields for a library override', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().includes('Save override'))
|
||||
expect(saveButton).toBeDefined()
|
||||
await saveButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const putCall = apiMock.mock.calls.find(
|
||||
([url, init]) => String(url) === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit)?.method === 'PUT',
|
||||
)
|
||||
expect(putCall).toBeDefined()
|
||||
const [, init] = putCall as [string, RequestInit]
|
||||
expect(JSON.parse(String(init.body))).toEqual(makeConfig(45))
|
||||
})
|
||||
|
||||
it('persists null override when switching back to global inheritance', async () => {
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeLibraryConfig())
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit).method === 'PUT')
|
||||
return response(makeInheritedLibraryConfig())
|
||||
return response({})
|
||||
})
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('[data-testid="toggle-switch"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const putCall = apiMock.mock.calls.find(
|
||||
([url, init]) => String(url) === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit)?.method === 'PUT',
|
||||
)
|
||||
expect(putCall).toBeDefined()
|
||||
const [, init] = putCall as [string, RequestInit]
|
||||
expect(JSON.parse(String(init.body))).toEqual({})
|
||||
expect(wrapper.text()).toContain('Inheriting global defaults')
|
||||
expect(wrapper.text()).not.toContain('Save override')
|
||||
})
|
||||
|
||||
it('records a zero-queued manual run in the library card', async () => {
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
if (path === '/api/v1/book-metadata-fetch/run/2' && (init as RequestInit).method === 'POST') return response({ queued: 0 })
|
||||
return response({})
|
||||
})
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const runButton = wrapper.findAll('button').find((button) => button.text().includes('Run now'))
|
||||
expect(runButton).toBeDefined()
|
||||
await runButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('No eligible books found')
|
||||
expect(wrapper.text()).toContain('Last run: just now - no eligible books')
|
||||
})
|
||||
|
||||
it('records a queued manual run in the library card', async () => {
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
if (path === '/api/v1/book-metadata-fetch/run/2' && (init as RequestInit).method === 'POST') return response({ queued: 5 })
|
||||
return response({})
|
||||
})
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const runButton = wrapper.findAll('button').find((button) => button.text().includes('Run now'))
|
||||
expect(runButton).toBeDefined()
|
||||
await runButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Queued 5 books')
|
||||
expect(wrapper.text()).toContain('Last run: just now - queued 5 books')
|
||||
})
|
||||
|
||||
it('allows an inherited library to start a local override without saving immediately', async () => {
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeInheritedLibraryConfig())
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
return response({})
|
||||
})
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.setProps({ globalConfig: makeConfig(80) })
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="toggle-switch"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const putCall = apiMock.mock.calls.find(
|
||||
([url, init]) => String(url) === '/api/v1/book-metadata-fetch/config/libraries/2' && (init as RequestInit)?.method === 'PUT',
|
||||
)
|
||||
expect(putCall).toBeUndefined()
|
||||
expect(wrapper.text()).toContain('Score < 80')
|
||||
expect(wrapper.text()).toContain('Save override')
|
||||
})
|
||||
|
||||
it('collapses the card and condition editor without losing state', async () => {
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-testid="condition-configurator"]').exists()).toBe(true)
|
||||
|
||||
const conditionButton = wrapper.findAll('button').find((button) => button.text().includes('Eligibility conditions'))
|
||||
expect(conditionButton).toBeDefined()
|
||||
await conditionButton!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="condition-configurator"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('Run now')
|
||||
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('Score < 45')
|
||||
})
|
||||
|
||||
it('summarizes missing-field and disabled override conditions', async () => {
|
||||
const disabledConfig = makeLibraryConfig()
|
||||
disabledConfig.enabled = false
|
||||
disabledConfig.conditions.neverFetched.enabled = true
|
||||
disabledConfig.conditions.missingFields = { enabled: true, fields: ['cover'] }
|
||||
disabledConfig.override = {
|
||||
enabled: false,
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
missingFields: { enabled: true, fields: ['cover'] },
|
||||
},
|
||||
}
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(disabledConfig)
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
return response({})
|
||||
})
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Never fetched')
|
||||
expect(wrapper.text()).toContain('Score < 45')
|
||||
expect(wrapper.text()).toContain('Missing 1 field')
|
||||
expect(wrapper.get('[data-testid="condition-configurator"]').attributes('data-disabled')).toBe('true')
|
||||
})
|
||||
|
||||
it('summarizes empty and plural missing-field override conditions', async () => {
|
||||
const config = makeLibraryConfig()
|
||||
config.conditions = {
|
||||
neverFetched: { enabled: false },
|
||||
scoreThreshold: { enabled: false, threshold: 45 },
|
||||
missingFields: { enabled: false, fields: ['cover', 'description'] },
|
||||
}
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(config)
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
return response({})
|
||||
})
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('No conditions enabled')
|
||||
|
||||
config.conditions.missingFields.enabled = true
|
||||
wrapper.unmount()
|
||||
const wrapper2 = mountComponent()
|
||||
await flushPromises()
|
||||
expect(wrapper2.text()).toContain('Missing 2 fields')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2026-01-10T11:50:00.000Z', null, 'Last run: 10m ago'],
|
||||
['2026-01-10T09:00:00.000Z', null, 'Last run: 3h ago'],
|
||||
['2026-01-09T12:00:00.000Z', null, 'Last run: yesterday'],
|
||||
['2026-01-06T12:00:00.000Z', null, 'Last run: 4 days ago'],
|
||||
])('formats previous run time %s', async (lastRunAt, lastQueuedCount, expected) => {
|
||||
vi.setSystemTime(new Date('2026-01-10T12:00:00.000Z'))
|
||||
apiMock.mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path === '/api/v1/book-metadata-fetch/config/libraries/2' && !init) return response(makeRunHistoryConfig(lastRunAt, lastQueuedCount))
|
||||
if (path === '/api/v1/book-metadata-fetch/preview-count') return response({ count: 8 })
|
||||
return response({})
|
||||
})
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain(expected)
|
||||
})
|
||||
})
|
||||
+38
-7
@@ -6,7 +6,7 @@ import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import ConditionConfigurator from './ConditionConfigurator.vue'
|
||||
import { useBookMetadataFetchConfig } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchConfig'
|
||||
import { useBookMetadataFetchActions } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchActions'
|
||||
import { useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
|
||||
import { invalidateEligibleCountPreviews, useEligibleCountPreview } from '@/features/book-metadata-fetch/composables/useEligibleCountPreview'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -60,8 +60,7 @@ const lastRunLabel = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
libraryData.value = await fetchLibraryConfig(props.library.id)
|
||||
local.value = JSON.parse(JSON.stringify(libraryData.value))
|
||||
applyLibraryData(await fetchLibraryConfig(props.library.id))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -69,10 +68,19 @@ onMounted(async () => {
|
||||
|
||||
const displayConfig = computed(() => (inheriting.value ? props.globalConfig : local.value) ?? props.globalConfig)
|
||||
|
||||
function handleInheritToggle(isInheriting: boolean) {
|
||||
async function handleInheritToggle(isInheriting: boolean) {
|
||||
const wasInheriting = inheriting.value
|
||||
inheriting.value = isInheriting
|
||||
if (isInheriting) {
|
||||
local.value = JSON.parse(JSON.stringify(props.globalConfig))
|
||||
triggerResult.value = null
|
||||
const nextConfig = isInheriting || wasInheriting ? props.globalConfig : (libraryData.value ?? props.globalConfig)
|
||||
local.value = cloneConfigOnly(nextConfig)
|
||||
if (!isInheriting) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
applyLibraryData(await saveLibraryConfig(props.library.id, null))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +89,7 @@ async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
const override = inheriting.value ? null : local.value
|
||||
await saveLibraryConfig(props.library.id, override)
|
||||
applyLibraryData(await saveLibraryConfig(props.library.id, override))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -93,6 +101,7 @@ async function handleTrigger() {
|
||||
try {
|
||||
const { queued } = await triggerForLibrary(props.library.id)
|
||||
triggerResult.value = queued > 0 ? `Queued ${queued} books` : 'No eligible books found'
|
||||
invalidateEligibleCountPreviews()
|
||||
if (libraryData.value) {
|
||||
libraryData.value.lastRunAt = new Date().toISOString()
|
||||
libraryData.value.lastQueuedCount = queued
|
||||
@@ -110,6 +119,28 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.globalConfig,
|
||||
(config) => {
|
||||
if (inheriting.value) local.value = cloneConfigOnly(config)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function applyLibraryData(data: BookMetadataFetchLibraryConfig) {
|
||||
libraryData.value = data
|
||||
inheriting.value = data.override === null
|
||||
local.value = cloneConfigOnly(data)
|
||||
}
|
||||
|
||||
function cloneConfigOnly(config: BookMetadataFetchConfig): BookMetadataFetchConfig {
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
triggerOnImport: config.triggerOnImport,
|
||||
conditions: JSON.parse(JSON.stringify(config.conditions)),
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -26,11 +26,21 @@ export interface BookMetadataFetchConfig {
|
||||
conditions: BookMetadataFetchConditions;
|
||||
}
|
||||
|
||||
export interface BookMetadataFetchConditionsOverride {
|
||||
scoreThreshold?: Partial<BookMetadataFetchScoreCondition>;
|
||||
missingFields?: Partial<BookMetadataFetchMissingFieldsCondition>;
|
||||
neverFetched?: Partial<BookMetadataFetchNeverFetchedCondition>;
|
||||
}
|
||||
|
||||
// Stored per-library. null = inherit everything from global.
|
||||
// Deep-merged by getEffectiveConfig: top-level keys first, then each conditions key individually.
|
||||
export type BookMetadataFetchConfigOverride = Partial<BookMetadataFetchConfig> | null;
|
||||
export type BookMetadataFetchConfigOverride =
|
||||
| (Partial<Omit<BookMetadataFetchConfig, "conditions">> & {
|
||||
conditions?: BookMetadataFetchConditionsOverride;
|
||||
})
|
||||
| null;
|
||||
|
||||
// 'done' is not a queue status — rows are deleted on success to prevent unbounded growth.
|
||||
// 'done' is not a queue status - rows are deleted on success to prevent unbounded growth.
|
||||
export type BookMetadataFetchQueueStatus = "queued" | "processing" | "failed";
|
||||
|
||||
export type BookMetadataFetchReason = "event_import" | "manual_trigger" | "manual_retry";
|
||||
@@ -53,6 +63,7 @@ export interface BookMetadataFetchStatusEvent extends BookMetadataFetchStatus {
|
||||
|
||||
// Library-level config response: effective config merged with per-library run history.
|
||||
export interface BookMetadataFetchLibraryConfig extends BookMetadataFetchConfig {
|
||||
override: BookMetadataFetchConfigOverride;
|
||||
lastRunAt: string | null;
|
||||
lastQueuedCount: number | null;
|
||||
}
|
||||
|
||||
@@ -132,8 +132,6 @@ describe('BookMetadataFetchConfigService', () => {
|
||||
});
|
||||
db.query.libraries.findFirst.mockResolvedValueOnce({
|
||||
bookMetadataFetchConfig: null,
|
||||
});
|
||||
db.query.libraries.findFirst.mockResolvedValueOnce({
|
||||
bookMetadataFetchLastRunAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
bookMetadataFetchLastQueuedCount: 17,
|
||||
});
|
||||
@@ -147,6 +145,7 @@ describe('BookMetadataFetchConfigService', () => {
|
||||
|
||||
await expect(service.getLibraryConfigWithLastRun(9)).resolves.toEqual({
|
||||
...DEFAULT_BOOK_METADATA_FETCH_CONFIG,
|
||||
override: null,
|
||||
lastRunAt: '2026-01-01T00:00:00.000Z',
|
||||
lastQueuedCount: 17,
|
||||
});
|
||||
@@ -159,4 +158,43 @@ describe('BookMetadataFetchConfigService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('getLibraryConfigWithLastRun includes stored override and effective config', async () => {
|
||||
const { db, service } = makeService();
|
||||
db.query.appSettings.findFirst.mockResolvedValueOnce({
|
||||
value: JSON.stringify({
|
||||
enabled: true,
|
||||
triggerOnImport: true,
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
scoreThreshold: { enabled: true, threshold: 70 },
|
||||
missingFields: { enabled: true, fields: ['cover'] },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const override = {
|
||||
enabled: false,
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 45 },
|
||||
},
|
||||
};
|
||||
db.query.libraries.findFirst.mockResolvedValueOnce({
|
||||
bookMetadataFetchConfig: override,
|
||||
bookMetadataFetchLastRunAt: null,
|
||||
bookMetadataFetchLastQueuedCount: null,
|
||||
});
|
||||
|
||||
await expect(service.getLibraryConfigWithLastRun(5)).resolves.toEqual({
|
||||
enabled: false,
|
||||
triggerOnImport: true,
|
||||
conditions: {
|
||||
neverFetched: { enabled: true },
|
||||
scoreThreshold: { enabled: true, threshold: 45 },
|
||||
missingFields: { enabled: true, fields: ['cover'] },
|
||||
},
|
||||
override,
|
||||
lastRunAt: null,
|
||||
lastQueuedCount: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,15 +56,22 @@ export class BookMetadataFetchConfigService {
|
||||
}
|
||||
|
||||
async getLibraryConfigWithLastRun(libraryId: number): Promise<BookMetadataFetchLibraryConfig> {
|
||||
const [config, library] = await Promise.all([
|
||||
this.getEffectiveConfig(libraryId),
|
||||
const [global, library] = await Promise.all([
|
||||
this.getGlobalConfig(),
|
||||
this.db.query.libraries.findFirst({
|
||||
where: eq(libraries.id, libraryId),
|
||||
columns: { bookMetadataFetchLastRunAt: true, bookMetadataFetchLastQueuedCount: true },
|
||||
columns: {
|
||||
bookMetadataFetchConfig: true,
|
||||
bookMetadataFetchLastRunAt: true,
|
||||
bookMetadataFetchLastQueuedCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const override = library?.bookMetadataFetchConfig ?? null;
|
||||
const config = this.deepMerge(global, override);
|
||||
return {
|
||||
...config,
|
||||
override,
|
||||
lastRunAt: library?.bookMetadataFetchLastRunAt?.toISOString() ?? null,
|
||||
lastQueuedCount: library?.bookMetadataFetchLastQueuedCount ?? null,
|
||||
};
|
||||
|
||||
+297
-6
@@ -1,4 +1,4 @@
|
||||
import { MetadataProviderKey } from '@bookorbit/types';
|
||||
import { MetadataProviderKey, NotificationType } from '@bookorbit/types';
|
||||
|
||||
import { BookMetadataFetchSessionService } from './book-metadata-fetch-session.service';
|
||||
import { BookMetadataFetchOrchestratorService } from './book-metadata-fetch-orchestrator.service';
|
||||
@@ -102,6 +102,7 @@ function makeService(withGateway = true) {
|
||||
session,
|
||||
throttleTracker,
|
||||
gateway,
|
||||
notificationService,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,12 +128,14 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
expect(gateway.emitStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('triggerForLibrary skips disabled configs and does not record runs', async () => {
|
||||
const { service, configService } = makeService();
|
||||
it('triggerForLibrary runs manually even when automatic fetch is disabled', async () => {
|
||||
const { service, configService, queueRepo } = makeService();
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(false, true));
|
||||
queueRepo.scheduleEligibleBooksInBatches.mockResolvedValue(0);
|
||||
|
||||
await expect(service.triggerForLibrary(11)).resolves.toBe(0);
|
||||
expect(configService.recordLibraryRun).not.toHaveBeenCalled();
|
||||
expect(queueRepo.scheduleEligibleBooksInBatches).toHaveBeenCalledWith(baseConfig(false, true), 'manual_trigger', 11, 1000);
|
||||
expect(configService.recordLibraryRun).toHaveBeenCalledWith(11, 0);
|
||||
});
|
||||
|
||||
it('triggerForLibrary records run and emits status for queued jobs', async () => {
|
||||
@@ -435,8 +438,14 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
expect(resetSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('scheduleIfEligible skips queue writes when import trigger is disabled or book is missing', async () => {
|
||||
const { service, configService, queueRepo, bookReadService } = makeService();
|
||||
it('scheduleIfEligible skips queue writes when disabled, import trigger is disabled, book is missing, or eligibility fails', async () => {
|
||||
const { service, configService, eligibilityService, queueRepo, bookReadService } = makeService();
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(false, true));
|
||||
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(bookReadService.findById).not.toHaveBeenCalled();
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
|
||||
configService.getEffectiveConfig.mockResolvedValue(baseConfig(true, false));
|
||||
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
@@ -446,5 +455,287 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
bookReadService.findById.mockResolvedValue(null);
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 2 },
|
||||
book_metadata: {
|
||||
metadataScore: 50,
|
||||
lastMetadataFetchAt: null,
|
||||
title: 'Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
coverSource: null,
|
||||
durationSeconds: null,
|
||||
abridged: null,
|
||||
},
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
});
|
||||
eligibilityService.isEligible.mockReturnValue(false);
|
||||
await service.scheduleIfEligible(1, 2, 'import' as never);
|
||||
expect(queueRepo.upsertSchedule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requeueFailed returns zero without session update', async () => {
|
||||
const { service, gateway, session } = makeService();
|
||||
|
||||
await expect(service.requeueFailed()).resolves.toBe(0);
|
||||
|
||||
expect(session.getSnapshot().sessionTotal).toBe(0);
|
||||
expect(gateway.emitStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onModuleDestroy is a no-op when polling was never started', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
expect(() => service.onModuleDestroy()).not.toThrow();
|
||||
});
|
||||
|
||||
it('pollOnce logs failures and clears the running flag', async () => {
|
||||
const { service, queueRepo } = makeService();
|
||||
queueRepo.recoverStuckProcessing.mockRejectedValue(new Error('queue unavailable'));
|
||||
|
||||
await (service as any).pollOnce();
|
||||
|
||||
expect((service as any).running).toBe(false);
|
||||
expect(queueRepo.fetchDue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('processOne passes fallback identifiers and collected provider ids to the pipeline', async () => {
|
||||
const { service, bookReadService, pipeline } = makeService();
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 5 },
|
||||
book_metadata: {
|
||||
title: null,
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn13: null,
|
||||
isbn10: 'isbn-10',
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
coverSource: null,
|
||||
durationSeconds: null,
|
||||
abridged: null,
|
||||
googleBooksId: 'google-1',
|
||||
goodreadsId: 'goodreads-1',
|
||||
amazonId: 'amazon-1',
|
||||
hardcoverId: 'hardcover-1',
|
||||
openLibraryId: 'open-library-1',
|
||||
itunesId: 'itunes-1',
|
||||
audibleId: 'audible-1',
|
||||
koboId: 'kobo-1',
|
||||
comicvineId: 'comicvine-1',
|
||||
ranobedbId: 'ranobedb-1',
|
||||
lubimyczytacId: 'lubimyczytac-1',
|
||||
aladinId: 'aladin-1',
|
||||
},
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
});
|
||||
|
||||
await (service as any).processOne(91, 'Book');
|
||||
|
||||
expect(pipeline.runWithSources).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: undefined,
|
||||
author: undefined,
|
||||
isbn: 'isbn-10',
|
||||
isAudiobook: true,
|
||||
existingProviderIds: {
|
||||
[MetadataProviderKey.GOOGLE]: 'google-1',
|
||||
[MetadataProviderKey.GOODREADS]: 'goodreads-1',
|
||||
[MetadataProviderKey.AMAZON]: 'amazon-1',
|
||||
[MetadataProviderKey.HARDCOVER]: 'hardcover-1',
|
||||
[MetadataProviderKey.OPEN_LIBRARY]: 'open-library-1',
|
||||
[MetadataProviderKey.ITUNES]: 'itunes-1',
|
||||
[MetadataProviderKey.AUDIBLE]: 'audible-1',
|
||||
[MetadataProviderKey.KOBO]: 'kobo-1',
|
||||
[MetadataProviderKey.COMICVINE]: 'comicvine-1',
|
||||
[MetadataProviderKey.RANOBEDB]: 'ranobedb-1',
|
||||
[MetadataProviderKey.LUBIMYCZYTAC]: 'lubimyczytac-1',
|
||||
[MetadataProviderKey.ALADIN]: 'aladin-1',
|
||||
},
|
||||
}),
|
||||
expect.any(Object),
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it('processOne records non-Error failures without an http status', async () => {
|
||||
const { service, queueRepo, bookReadService, pipeline } = makeService();
|
||||
bookReadService.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { libraryId: 1 },
|
||||
book_metadata: {
|
||||
title: 'Book',
|
||||
subtitle: null,
|
||||
description: null,
|
||||
isbn13: null,
|
||||
isbn10: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
pageCount: null,
|
||||
seriesName: null,
|
||||
seriesIndex: null,
|
||||
coverSource: null,
|
||||
durationSeconds: null,
|
||||
abridged: null,
|
||||
},
|
||||
},
|
||||
authorRows: [],
|
||||
genreRows: [],
|
||||
narratorRows: [],
|
||||
});
|
||||
pipeline.runWithSources.mockRejectedValue('provider down');
|
||||
|
||||
await (service as any).processOne(92, 'Book');
|
||||
|
||||
expect(queueRepo.markFailed).toHaveBeenCalledWith(92, 'provider down', undefined);
|
||||
});
|
||||
|
||||
it('persistResolved ignores unsupported values and empty new related lists', async () => {
|
||||
const { service, bookReadService, bookMetadataLockService, metadataService } = makeService();
|
||||
bookMetadataLockService.filterResolvedMetadata.mockResolvedValue({
|
||||
resolved: {
|
||||
title: 123,
|
||||
subtitle: [],
|
||||
description: {},
|
||||
publisher: false,
|
||||
publishedYear: '2020',
|
||||
language: 7,
|
||||
pageCount: Number.POSITIVE_INFINITY,
|
||||
seriesName: Symbol('series'),
|
||||
seriesIndex: '2',
|
||||
duration: '3600',
|
||||
abridged: null,
|
||||
authors: [],
|
||||
genres: [],
|
||||
narrators: [],
|
||||
},
|
||||
providerIds: {},
|
||||
skippedFields: ['title', 'cover'],
|
||||
});
|
||||
|
||||
await (service as any).persistResolved(93, {}, {}, [], [], []);
|
||||
|
||||
const fields = bookReadService.updateMetadataFields.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(fields).toEqual({
|
||||
lastMetadataFetchAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
expect(metadataService.replaceAuthors).not.toHaveBeenCalled();
|
||||
expect(metadataService.replaceGenres).not.toHaveBeenCalled();
|
||||
expect(metadataService.replaceNarrators).not.toHaveBeenCalled();
|
||||
expect(metadataService.upsertComicMetadata).not.toHaveBeenCalled();
|
||||
expect(metadataService.downloadAndSaveCover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persistResolved clears existing related rows when providers return empty lists', async () => {
|
||||
const { service, bookMetadataLockService, metadataService } = makeService();
|
||||
bookMetadataLockService.filterResolvedMetadata.mockResolvedValue({
|
||||
resolved: {
|
||||
authors: [],
|
||||
genres: [],
|
||||
narrators: [],
|
||||
},
|
||||
providerIds: {},
|
||||
skippedFields: [],
|
||||
});
|
||||
|
||||
await (service as any).persistResolved(94, {}, {}, [{ name: 'Old A' }], [{ name: 'Old G' }], [{ name: 'Old N' }]);
|
||||
|
||||
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(94, []);
|
||||
expect(metadataService.replaceGenres).toHaveBeenCalledWith(94, []);
|
||||
expect(metadataService.replaceNarrators).toHaveBeenCalledWith(94, []);
|
||||
});
|
||||
|
||||
it('randomDelay uses the normal delay window when no provider is throttled', async () => {
|
||||
vi.useFakeTimers();
|
||||
const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
const timeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
try {
|
||||
const { service } = makeService();
|
||||
|
||||
const waitPromise = (service as any).randomDelay();
|
||||
expect(timeoutSpy).toHaveBeenCalledTimes(1);
|
||||
const delayMs = timeoutSpy.mock.calls[0]?.[1] as number;
|
||||
expect(delayMs).toBe(2_000);
|
||||
vi.runAllTimers();
|
||||
await waitPromise;
|
||||
} finally {
|
||||
randomSpy.mockRestore();
|
||||
timeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('checkAndResetSession sends completion and failure notifications before resetting', async () => {
|
||||
const { service, queueRepo, session, notificationService } = makeService();
|
||||
session.addToTotal(2);
|
||||
session.incrementDone();
|
||||
queueRepo.getStatusSummary.mockResolvedValueOnce({ queued: 0, processing: 0, failed: 0 });
|
||||
|
||||
await (service as any).checkAndResetSession();
|
||||
|
||||
session.addToTotal(3);
|
||||
session.incrementDone();
|
||||
queueRepo.getStatusSummary.mockResolvedValueOnce({ queued: 0, processing: 0, failed: 1 });
|
||||
|
||||
await (service as any).checkAndResetSession();
|
||||
|
||||
expect(notificationService.notify).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
type: NotificationType.MetadataFetchCompleted,
|
||||
title: 'Metadata fetch completed',
|
||||
message: 'Processed 1 of 2 books',
|
||||
}),
|
||||
);
|
||||
expect(notificationService.notify).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
type: NotificationType.MetadataFetchFailed,
|
||||
title: 'Metadata fetch completed with errors',
|
||||
message: 'Processed 1 of 3 books, 1 failed',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('metadata value coercion helpers accept only supported shapes', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
expect((service as any).asNullableString(undefined)).toBeUndefined();
|
||||
expect((service as any).asNullableString(null)).toBeNull();
|
||||
expect((service as any).asNullableString(1)).toBeUndefined();
|
||||
expect((service as any).asNullableString('value')).toBe('value');
|
||||
expect((service as any).asNullableNumber(undefined)).toBeUndefined();
|
||||
expect((service as any).asNullableNumber(null)).toBeNull();
|
||||
expect((service as any).asNullableNumber(Number.NaN)).toBeUndefined();
|
||||
expect((service as any).asNullableNumber(5)).toBe(5);
|
||||
expect((service as any).asBoolean(undefined)).toBeUndefined();
|
||||
expect((service as any).asBoolean(null)).toBeUndefined();
|
||||
expect((service as any).asBoolean('false')).toBeUndefined();
|
||||
expect((service as any).asBoolean(false)).toBe(false);
|
||||
expect((service as any).asStringArray(undefined)).toBeUndefined();
|
||||
expect((service as any).asStringArray('value')).toBeUndefined();
|
||||
expect((service as any).asStringArray([1])).toBeUndefined();
|
||||
expect((service as any).asStringArray(['value'])).toEqual(['value']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,9 +90,8 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
|
||||
async triggerForLibrary(libraryId: number): Promise<number> {
|
||||
const config = await this.configService.getEffectiveConfig(libraryId);
|
||||
if (!config.enabled) return 0;
|
||||
|
||||
const queued = await this.queueRepo.scheduleEligibleBooksInBatches(config, 'manual_trigger', libraryId, SCHEDULE_BATCH_SIZE);
|
||||
await this.configService.recordLibraryRun(libraryId, queued);
|
||||
if (queued === 0) return 0;
|
||||
if (queued > 0) {
|
||||
this.session.addToTotal(queued);
|
||||
@@ -100,7 +99,6 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
await this.emitStatus();
|
||||
void this.pollOnce();
|
||||
}
|
||||
await this.configService.recordLibraryRun(libraryId, queued);
|
||||
return queued;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,13 +99,13 @@ describe('BookMetadataFetchController', () => {
|
||||
});
|
||||
|
||||
it('gets library config and writes null override for empty payloads', async () => {
|
||||
configService.getLibraryConfigWithLastRun.mockResolvedValue({ enabled: true });
|
||||
configService.getEffectiveConfig.mockResolvedValue({ enabled: false });
|
||||
configService.getLibraryConfigWithLastRun.mockResolvedValueOnce({ enabled: true }).mockResolvedValueOnce({ enabled: false });
|
||||
configService.setLibraryOverride.mockResolvedValue(undefined);
|
||||
|
||||
await expect(controller.getLibraryConfig(22)).resolves.toEqual({ enabled: true });
|
||||
await expect(controller.updateLibraryConfig(22, {} as never)).resolves.toEqual({ enabled: false });
|
||||
expect(configService.setLibraryOverride).toHaveBeenCalledWith(22, null);
|
||||
expect(configService.getLibraryConfigWithLastRun).toHaveBeenCalledWith(22);
|
||||
});
|
||||
|
||||
it('returns status summary merged with paused state and session snapshot', async () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ export class BookMetadataFetchController {
|
||||
async updateLibraryConfig(@Param('id', ParseIntPipe) libraryId: number, @Body() dto: UpdateLibraryBookMetadataFetchConfigDto) {
|
||||
const override: BookMetadataFetchConfigOverride = Object.keys(dto).length === 0 ? null : dto;
|
||||
await this.configService.setLibraryOverride(libraryId, override);
|
||||
return this.configService.getEffectiveConfig(libraryId);
|
||||
return this.configService.getLibraryConfigWithLastRun(libraryId);
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsObject, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
import type { BookMetadataFetchConditions, BookMetadataFetchConfig, MetadataField } from '@bookorbit/types';
|
||||
import { IsArray, IsBoolean, IsDefined, IsIn, IsInt, IsObject, IsOptional, Max, Min, ValidateNested } from 'class-validator';
|
||||
import type {
|
||||
BookMetadataFetchConditions,
|
||||
BookMetadataFetchConditionsOverride,
|
||||
BookMetadataFetchConfig,
|
||||
BookMetadataFetchConfigOverride,
|
||||
MetadataField,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { ALL_METADATA_FIELDS } from '@bookorbit/types';
|
||||
|
||||
class ScoreConditionDto {
|
||||
@IsDefined()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsDefined()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
@@ -15,46 +23,109 @@ class ScoreConditionDto {
|
||||
}
|
||||
|
||||
class MissingFieldsConditionDto {
|
||||
@IsDefined()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsDefined()
|
||||
@IsArray()
|
||||
@IsIn(ALL_METADATA_FIELDS, { each: true })
|
||||
fields: MetadataField[];
|
||||
}
|
||||
|
||||
class NeverFetchedConditionDto {
|
||||
@IsDefined()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
class ConditionsDto implements BookMetadataFetchConditions {
|
||||
@IsDefined()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ScoreConditionDto)
|
||||
scoreThreshold: ScoreConditionDto;
|
||||
|
||||
@IsDefined()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => MissingFieldsConditionDto)
|
||||
missingFields: MissingFieldsConditionDto;
|
||||
|
||||
@IsDefined()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => NeverFetchedConditionDto)
|
||||
neverFetched: NeverFetchedConditionDto;
|
||||
}
|
||||
|
||||
class ScoreConditionOverrideDto implements Partial<ScoreConditionDto> {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100)
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
class MissingFieldsConditionOverrideDto implements Partial<MissingFieldsConditionDto> {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(ALL_METADATA_FIELDS, { each: true })
|
||||
fields?: MetadataField[];
|
||||
}
|
||||
|
||||
class NeverFetchedConditionOverrideDto implements Partial<NeverFetchedConditionDto> {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
class ConditionsOverrideDto implements BookMetadataFetchConditionsOverride {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ScoreConditionOverrideDto)
|
||||
scoreThreshold?: ScoreConditionOverrideDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => MissingFieldsConditionOverrideDto)
|
||||
missingFields?: MissingFieldsConditionOverrideDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => NeverFetchedConditionOverrideDto)
|
||||
neverFetched?: NeverFetchedConditionOverrideDto;
|
||||
}
|
||||
|
||||
export class UpdateBookMetadataFetchConfigDto implements BookMetadataFetchConfig {
|
||||
@IsDefined()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@IsDefined()
|
||||
@IsBoolean()
|
||||
triggerOnImport: boolean;
|
||||
|
||||
@IsDefined()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ConditionsDto)
|
||||
conditions: ConditionsDto;
|
||||
}
|
||||
|
||||
export class PreviewCountDto {
|
||||
@IsDefined()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ConditionsDto)
|
||||
@@ -65,7 +136,7 @@ export class PreviewCountDto {
|
||||
libraryId?: number;
|
||||
}
|
||||
|
||||
export class UpdateLibraryBookMetadataFetchConfigDto implements Partial<BookMetadataFetchConfig> {
|
||||
export class UpdateLibraryBookMetadataFetchConfigDto implements NonNullable<BookMetadataFetchConfigOverride> {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
@@ -75,7 +146,8 @@ export class UpdateLibraryBookMetadataFetchConfigDto implements Partial<BookMeta
|
||||
triggerOnImport?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type(() => ConditionsDto)
|
||||
conditions?: BookMetadataFetchConditions;
|
||||
@Type(() => ConditionsOverrideDto)
|
||||
conditions?: BookMetadataFetchConditionsOverride;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,20 @@ describe('Book metadata fetch config DTO validation', () => {
|
||||
expect((await errorsFor(UpdateBookMetadataFetchConfigDto, badField)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects incomplete global and preview condition payloads', async () => {
|
||||
const incomplete = {
|
||||
enabled: true,
|
||||
triggerOnImport: true,
|
||||
conditions: {
|
||||
scoreThreshold: { enabled: true, threshold: 50 },
|
||||
missingFields: { enabled: true, fields: ['description'] },
|
||||
},
|
||||
};
|
||||
|
||||
expect((await errorsFor(UpdateBookMetadataFetchConfigDto, incomplete)).length).toBeGreaterThan(0);
|
||||
expect((await errorsFor(PreviewCountDto, { conditions: incomplete.conditions })).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('PreviewCountDto requires nested conditions and validates optional library id', async () => {
|
||||
const valid = {
|
||||
conditions: {
|
||||
@@ -65,6 +79,15 @@ describe('Book metadata fetch config DTO validation', () => {
|
||||
|
||||
it('UpdateLibraryBookMetadataFetchConfigDto allows partial override payloads', async () => {
|
||||
expect((await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, { enabled: true })).length).toBe(0);
|
||||
expect(
|
||||
(
|
||||
await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, {
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 45 },
|
||||
},
|
||||
})
|
||||
).length,
|
||||
).toBe(0);
|
||||
expect(
|
||||
(
|
||||
await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, {
|
||||
@@ -77,4 +100,34 @@ describe('Book metadata fetch config DTO validation', () => {
|
||||
).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('UpdateLibraryBookMetadataFetchConfigDto validates provided partial override fields', async () => {
|
||||
expect(
|
||||
(
|
||||
await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, {
|
||||
conditions: {
|
||||
scoreThreshold: { threshold: 101 },
|
||||
},
|
||||
})
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
(
|
||||
await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, {
|
||||
conditions: {
|
||||
missingFields: { fields: ['not_a_field'] },
|
||||
},
|
||||
})
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
(
|
||||
await errorsFor(UpdateLibraryBookMetadataFetchConfigDto, {
|
||||
conditions: {
|
||||
scoreThreshold: true,
|
||||
},
|
||||
})
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user