From 484ad0b9be2ebd3ef54bb08eb6fb405f259c7dc3 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:48:30 +0000 Subject: [PATCH] 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. --- .../composables/useBookMetadataFetchConfig.ts | 2 +- .../useEligibleCountPreview.spec.ts | 111 ++++++ .../composables/useEligibleCountPreview.ts | 22 +- .../components/GlobalAutoFetchConfig.vue | 3 +- .../components/LibraryAutoFetchConfig.spec.ts | 362 ++++++++++++++++++ .../components/LibraryAutoFetchConfig.vue | 45 ++- packages/types/src/book-metadata-fetch.ts | 15 +- ...book-metadata-fetch-config.service.test.ts | 42 +- .../book-metadata-fetch-config.service.ts | 13 +- ...etadata-fetch-orchestrator.service.test.ts | 303 ++++++++++++++- ...ook-metadata-fetch-orchestrator.service.ts | 4 +- .../book-metadata-fetch.controller.test.ts | 4 +- .../book-metadata-fetch.controller.ts | 2 +- .../dto/update-config.dto.ts | 82 +++- .../update-config.dto.test.ts | 53 +++ 15 files changed, 1027 insertions(+), 36 deletions(-) create mode 100644 client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts create mode 100644 client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts diff --git a/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts b/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts index f872257b4..225ba5361 100644 --- a/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts +++ b/client/src/features/book-metadata-fetch/composables/useBookMetadataFetchConfig.ts @@ -31,7 +31,7 @@ export function useBookMetadataFetchConfig() { return res.json() } - async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise { + async function saveLibraryConfig(libraryId: number, override: BookMetadataFetchConfigOverride): Promise { const res = await api(`/api/v1/book-metadata-fetch/config/libraries/${libraryId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts new file mode 100644 index 000000000..503b47a75 --- /dev/null +++ b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.spec.ts @@ -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 }>>()) + +vi.mock('@/lib/api', () => ({ + api: (...args: unknown[]) => apiMock(...args), +})) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((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 }>() + const second = deferred<{ ok: boolean; json: () => Promise }>() + 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(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) + }) +}) diff --git a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts index 678f390e0..18e3ad951 100644 --- a/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts +++ b/client/src/features/book-metadata-fetch/composables/useEligibleCountPreview.ts @@ -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, libraryId?: number) { const count = ref(null) const loading = ref(false) let debounceTimer: ReturnType | 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 { + requestId += 1 if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(fetchCount, 400) }, { deep: true, immediate: true }, ) + onScopeDispose(() => { + requestId += 1 + if (debounceTimer) clearTimeout(debounceTimer) + }) + return { count, loading } } diff --git a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue b/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue index 31648b2a5..c232893f0 100644 --- a/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue +++ b/client/src/features/settings/metadata-auto-fetch/components/GlobalAutoFetchConfig.vue @@ -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 } diff --git a/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts new file mode 100644 index 000000000..f05d20428 --- /dev/null +++ b/client/src/features/settings/metadata-auto-fetch/components/LibraryAutoFetchConfig.spec.ts @@ -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 }>>()) + +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: '