mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(settings): improve migration source validation workflow
Improve migration setup feedback so operators can validate credentials and media paths with actionable status instead of ambiguous failures. Keep users on their current setup step while saving to avoid accidental context switches during preflight configuration.
This commit is contained in:
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { Eye, EyeOff, Loader2, X, ChevronLeft, ChevronRight, Check, AlertCircle } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import SearchableUserSelect from './SearchableUserSelect.vue'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
cancelRun,
|
||||
createDryRunPlan,
|
||||
@@ -75,6 +76,8 @@ const supportedTypes = ref<string[]>([])
|
||||
const targetUsers = ref<TargetUser[]>([])
|
||||
const targetLibraryFolders = ref<Array<{ libraryName: string; path: string }>>([])
|
||||
const sourcePathPrefixes = ref<string[]>([])
|
||||
const mediaPathTestState = ref<'idle' | 'pass' | 'fail'>('idle')
|
||||
const mediaPathTestMessage = ref<string | null>(null)
|
||||
|
||||
const workflowState = ref<MigrationWorkflowState | null>(null)
|
||||
const runProgress = ref<MigrationRunProgress | null>(null)
|
||||
@@ -99,8 +102,10 @@ const sourceDraft = reactive({
|
||||
|
||||
const busy = reactive({
|
||||
testingSource: false,
|
||||
testingMediaPath: false,
|
||||
savingSource: false,
|
||||
loadingSuggestions: false,
|
||||
loadingTargetUsers: false,
|
||||
savingProfile: false,
|
||||
dryRun: false,
|
||||
startingRun: false,
|
||||
@@ -123,6 +128,25 @@ const hasActiveRun = computed(() => workflowState.value?.hasActiveRun === true)
|
||||
const sourceCapabilities = computed<MigrationSourceCapabilities | null>(() => source.value?.capabilities ?? null)
|
||||
const sourceValidationWarnings = computed(() => sourceCapabilities.value?.warnings ?? [])
|
||||
const sourceRowCounts = computed(() => Object.entries(sourceCapabilities.value?.counts ?? {}).sort(([left], [right]) => left.localeCompare(right)))
|
||||
const mediaRootPathHint = computed(() => {
|
||||
const path = sourceDraft.mediaRootPath.trim()
|
||||
if (!path) {
|
||||
return {
|
||||
className: 'text-amber-600',
|
||||
text: 'Required for migrating book covers.',
|
||||
}
|
||||
}
|
||||
if (!path.startsWith('/')) {
|
||||
return {
|
||||
className: 'text-amber-600',
|
||||
text: 'Use an absolute path (for example: /books). Relative paths are not supported.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
className: 'text-muted-foreground',
|
||||
text: 'Cover import path configured. Use Test Connection to verify access and Booklore images folder structure.',
|
||||
}
|
||||
})
|
||||
|
||||
const preflight = computed(() => {
|
||||
const issues: string[] = []
|
||||
@@ -193,9 +217,29 @@ const activeStepIndex = computed(() => {
|
||||
const currentStep = ref(0)
|
||||
|
||||
watch(activeStepIndex, (newVal) => {
|
||||
const shouldHoldSourceStep = busy.savingSource && currentStep.value === 0 && newVal === 1
|
||||
const shouldHoldMappingsStep = busy.savingProfile && currentStep.value === 1 && newVal === 2
|
||||
const shouldHoldDryRunStep = busy.dryRun && currentStep.value === 2 && newVal === 3
|
||||
if (shouldHoldSourceStep || shouldHoldMappingsStep || shouldHoldDryRunStep) return
|
||||
if (newVal > currentStep.value) currentStep.value = newVal
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [
|
||||
sourceDraft.mediaRootPath,
|
||||
sourceDraft.host,
|
||||
sourceDraft.port,
|
||||
sourceDraft.user,
|
||||
sourceDraft.password,
|
||||
sourceDraft.database,
|
||||
sourceDraft.ssl,
|
||||
],
|
||||
() => {
|
||||
mediaPathTestState.value = 'idle'
|
||||
mediaPathTestMessage.value = null
|
||||
},
|
||||
)
|
||||
|
||||
function onStepClick(index: number) {
|
||||
currentStep.value = index
|
||||
}
|
||||
@@ -265,6 +309,13 @@ interface DuplicateBookMatch {
|
||||
targetBookId: number
|
||||
sourceBookIds: string[]
|
||||
strategies: string[]
|
||||
sourceCandidates?: Array<{
|
||||
sourceBookId: string
|
||||
title: string | null
|
||||
author: string | null
|
||||
filePath: string | null
|
||||
strategy: string
|
||||
}>
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -276,6 +327,131 @@ const duplicateMatches = computed<DuplicateBookMatch[]>(() => {
|
||||
})
|
||||
|
||||
const duplicateResolutions = ref<Map<number, string>>(new Map())
|
||||
const duplicateTargetBookLabels = ref<Map<number, string>>(new Map())
|
||||
const duplicateGroupsRemaining = computed(() => duplicateMatches.value.filter((dup) => !duplicateResolutions.value.has(dup.targetBookId)).length)
|
||||
|
||||
const STRATEGY_PRIORITY: Record<string, number> = {
|
||||
isbn: 40,
|
||||
file_hash: 30,
|
||||
path_mapping: 20,
|
||||
title_author: 10,
|
||||
}
|
||||
|
||||
function strategyForSource(dup: DuplicateBookMatch, sourceId: string): string | null {
|
||||
const candidateStrategy = dup.sourceCandidates?.find((candidate) => candidate.sourceBookId === sourceId)?.strategy
|
||||
if (typeof candidateStrategy === 'string' && candidateStrategy.length > 0) return candidateStrategy
|
||||
const index = dup.sourceBookIds.indexOf(sourceId)
|
||||
if (index < 0) return null
|
||||
const strategy = dup.strategies[index]
|
||||
return typeof strategy === 'string' ? strategy : null
|
||||
}
|
||||
|
||||
function sourceBookLabel(dup: DuplicateBookMatch, sourceId: string): string {
|
||||
const candidate = dup.sourceCandidates?.find((row) => row.sourceBookId === sourceId)
|
||||
const title = candidate?.title?.trim()
|
||||
if (title) return title
|
||||
return `Source record ID #${sourceId}`
|
||||
}
|
||||
|
||||
function sourceBookSecondary(dup: DuplicateBookMatch, sourceId: string): string | null {
|
||||
const candidate = dup.sourceCandidates?.find((row) => row.sourceBookId === sourceId)
|
||||
const author = candidate?.author?.trim()
|
||||
if (author) return `by ${author} (ID: ${sourceId})`
|
||||
const filePath = candidate?.filePath?.trim()
|
||||
if (filePath) return `${filePath} (ID: ${sourceId})`
|
||||
return `Booklore source ID: ${sourceId}`
|
||||
}
|
||||
|
||||
function recommendedSourceBookId(dup: DuplicateBookMatch): string | null {
|
||||
let bestScore = Number.NEGATIVE_INFINITY
|
||||
let bestSourceId: string | null = null
|
||||
let tie = false
|
||||
|
||||
for (const sourceId of dup.sourceBookIds) {
|
||||
const strategy = strategyForSource(dup, sourceId)
|
||||
const score = strategy ? (STRATEGY_PRIORITY[strategy] ?? 0) : 0
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestSourceId = sourceId
|
||||
tie = false
|
||||
continue
|
||||
}
|
||||
if (score === bestScore) tie = true
|
||||
}
|
||||
|
||||
if (!bestSourceId || tie) return null
|
||||
return bestSourceId
|
||||
}
|
||||
|
||||
function isRecommendedSourceBook(dup: DuplicateBookMatch, sourceId: string): boolean {
|
||||
return recommendedSourceBookId(dup) === sourceId
|
||||
}
|
||||
|
||||
function targetBookLabel(targetBookId: number): string {
|
||||
return duplicateTargetBookLabels.value.get(targetBookId) ?? `Library book #${targetBookId}`
|
||||
}
|
||||
|
||||
function pruneDuplicateResolutions() {
|
||||
const activeTargetIds = new Set(duplicateMatches.value.map((dup) => dup.targetBookId))
|
||||
const keptResolutions = new Map<number, string>()
|
||||
const keptLabels = new Map<number, string>()
|
||||
|
||||
for (const [targetBookId, sourceBookId] of duplicateResolutions.value) {
|
||||
if (activeTargetIds.has(targetBookId)) keptResolutions.set(targetBookId, sourceBookId)
|
||||
}
|
||||
for (const [targetBookId, label] of duplicateTargetBookLabels.value) {
|
||||
if (activeTargetIds.has(targetBookId)) keptLabels.set(targetBookId, label)
|
||||
}
|
||||
|
||||
duplicateResolutions.value = keptResolutions
|
||||
duplicateTargetBookLabels.value = keptLabels
|
||||
}
|
||||
|
||||
function applyRecommendedDuplicateSelections() {
|
||||
const next = new Map(duplicateResolutions.value)
|
||||
for (const dup of duplicateMatches.value) {
|
||||
if (next.has(dup.targetBookId)) continue
|
||||
const recommendedSource = recommendedSourceBookId(dup)
|
||||
if (!recommendedSource) continue
|
||||
next.set(dup.targetBookId, recommendedSource)
|
||||
}
|
||||
duplicateResolutions.value = next
|
||||
}
|
||||
|
||||
async function loadDuplicateTargetBookLabels() {
|
||||
const missingTargetIds = [...new Set(duplicateMatches.value.map((dup) => dup.targetBookId))].filter(
|
||||
(targetBookId) => !duplicateTargetBookLabels.value.has(targetBookId),
|
||||
)
|
||||
if (missingTargetIds.length === 0) return
|
||||
|
||||
const rows = await Promise.all(
|
||||
missingTargetIds.map(async (targetBookId) => {
|
||||
try {
|
||||
const response = await api(`/api/v1/books/${targetBookId}`)
|
||||
if (!response.ok) return [targetBookId, `Library book #${targetBookId}`] as const
|
||||
const payload = asRecord(await response.json())
|
||||
return [targetBookId, asString(payload.title) ?? `Library book #${targetBookId}`] as const
|
||||
} catch {
|
||||
return [targetBookId, `Library book #${targetBookId}`] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const next = new Map(duplicateTargetBookLabels.value)
|
||||
for (const [targetBookId, label] of rows) next.set(targetBookId, label)
|
||||
duplicateTargetBookLabels.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
duplicateMatches,
|
||||
() => {
|
||||
pruneDuplicateResolutions()
|
||||
applyRecommendedDuplicateSelections()
|
||||
void loadDuplicateTargetBookLabels()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const reportData = computed(() => {
|
||||
const metrics = runReport.value?.metrics ?? runProgress.value?.metrics ?? []
|
||||
@@ -398,10 +574,7 @@ onUnmounted(() => {
|
||||
async function initialize() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [typesRes, usersRes, foldersRes] = await Promise.all([listSupportedSourceTypes(), listTargetUsers(), listTargetLibraryFolders()])
|
||||
supportedTypes.value = typesRes
|
||||
targetUsers.value = usersRes
|
||||
targetLibraryFolders.value = foldersRes
|
||||
await Promise.all([loadSupportedTypes(), loadTargetUsers(), loadTargetLibraryFolders()])
|
||||
await refreshWorkflowState()
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to initialize migration settings'))
|
||||
@@ -410,12 +583,43 @@ async function initialize() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSupportedTypes() {
|
||||
try {
|
||||
supportedTypes.value = await listSupportedSourceTypes()
|
||||
} catch (error) {
|
||||
supportedTypes.value = []
|
||||
toast.error(getErrorMessage(error, 'Failed to load supported migration source types'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTargetUsers() {
|
||||
busy.loadingTargetUsers = true
|
||||
try {
|
||||
targetUsers.value = await listTargetUsers()
|
||||
} catch (error) {
|
||||
targetUsers.value = []
|
||||
toast.error(getErrorMessage(error, 'Failed to load target users'))
|
||||
} finally {
|
||||
busy.loadingTargetUsers = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTargetLibraryFolders() {
|
||||
try {
|
||||
targetLibraryFolders.value = await listTargetLibraryFolders()
|
||||
} catch (error) {
|
||||
targetLibraryFolders.value = []
|
||||
toast.error(getErrorMessage(error, 'Failed to load target library folders'))
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkflowState() {
|
||||
workflowState.value = await getWorkflowState()
|
||||
hydrateSourceDraft()
|
||||
hydratePathMappings()
|
||||
hydrateUserMappingsFromProfile()
|
||||
if (source.value?.lastValidatedAt) {
|
||||
if (targetUsers.value.length === 0) await loadTargetUsers()
|
||||
await hydrateUserMappingsFromSuggestions(false)
|
||||
await autoLoadPathPrefixes()
|
||||
}
|
||||
@@ -466,10 +670,11 @@ async function hydrateUserMappingsFromSuggestions(showSuccessToast: boolean) {
|
||||
const response = await suggestUserMappings(currentSource.id)
|
||||
suggestionsLoadedAt.value = response.generatedAt
|
||||
const savedMappings = new Map((profile.value?.userMappings ?? []).map((row) => [row.sourceUserId, row.targetUserId]))
|
||||
const fallbackSingleTargetUserId = targetUsers.value.length === 1 ? (targetUsers.value[0]?.id ?? null) : null
|
||||
userMappings.value = response.suggestions.map((row) => ({
|
||||
sourceUserId: row.sourceUserId,
|
||||
username: row.username,
|
||||
targetUserId: savedMappings.get(row.sourceUserId) ?? row.suggestedTargetUserId,
|
||||
targetUserId: savedMappings.get(row.sourceUserId) ?? row.suggestedTargetUserId ?? fallbackSingleTargetUserId,
|
||||
}))
|
||||
if (showSuccessToast) {
|
||||
if (userMappings.value.length === 0) toast.warning('No source users were returned')
|
||||
@@ -498,6 +703,14 @@ function hasValidSourceDraft() {
|
||||
return !!sourceDraft.name.trim() && !!sourceDraft.host.trim() && !!sourceDraft.user.trim() && !!sourceDraft.database.trim()
|
||||
}
|
||||
|
||||
function extractWarningStrings(result: Record<string, unknown>): string[] {
|
||||
return Array.isArray(result.warnings) ? (result.warnings as unknown[]).filter((w): w is string => typeof w === 'string') : []
|
||||
}
|
||||
|
||||
function extractMediaPathWarnings(result: Record<string, unknown>): string[] {
|
||||
return extractWarningStrings(result).filter((warning) => /mediarootpath/i.test(warning))
|
||||
}
|
||||
|
||||
async function onTestSource() {
|
||||
if (!hasValidSourceDraft()) {
|
||||
toast.error('Host, user, database, and source name are required')
|
||||
@@ -507,8 +720,20 @@ async function onTestSource() {
|
||||
try {
|
||||
const result = await testSource({ type: sourceDraft.type, connectionConfig: buildSourceConnectionConfig() })
|
||||
const missing = Array.isArray(result.missingTables) ? result.missingTables.length : 0
|
||||
if (result.ok === true) toast.success('Connection test passed')
|
||||
else toast.warning(`Connection ok, but ${missing} required table(s) are missing`)
|
||||
const warnings = extractWarningStrings(result)
|
||||
if (result.ok === true) {
|
||||
if (warnings.length > 0) {
|
||||
const mediaPathWarning = warnings.find((warning) => /mediarootpath/i.test(warning))
|
||||
const highlightedWarning = mediaPathWarning ?? warnings[0] ?? 'Connection test passed with warnings'
|
||||
toast.warning(
|
||||
warnings.length === 1 ? highlightedWarning : `Connection test passed with ${warnings.length} warning(s). First: ${highlightedWarning}`,
|
||||
)
|
||||
} else {
|
||||
toast.success('Connection test passed')
|
||||
}
|
||||
} else {
|
||||
toast.warning(`Connection ok, but ${missing} required table(s) are missing`)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(friendlyConnectionError(error))
|
||||
} finally {
|
||||
@@ -516,6 +741,60 @@ async function onTestSource() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onTestMediaPath() {
|
||||
if (hasActiveRun.value) {
|
||||
toast.error('Cannot test media path while a run is active')
|
||||
return
|
||||
}
|
||||
if (!hasValidSourceDraft()) {
|
||||
toast.error('Host, user, database, and source name are required')
|
||||
return
|
||||
}
|
||||
const mediaRootPath = sourceDraft.mediaRootPath.trim()
|
||||
if (!mediaRootPath) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = 'Media Root Path is required for cover import.'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
if (!mediaRootPath.startsWith('/')) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = 'Use an absolute path (for example: /books).'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
|
||||
busy.testingMediaPath = true
|
||||
try {
|
||||
const result = await testSource({ type: sourceDraft.type, connectionConfig: buildSourceConnectionConfig() })
|
||||
const mediaWarnings = extractMediaPathWarnings(result)
|
||||
if (result.ok === true && mediaWarnings.length === 0) {
|
||||
mediaPathTestState.value = 'pass'
|
||||
mediaPathTestMessage.value = 'Media path verified.'
|
||||
toast.success('Media path is valid and readable')
|
||||
} else if (mediaWarnings.length > 0) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = mediaWarnings[0] ?? 'Media path validation failed.'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.warning(message)
|
||||
} else {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = 'Connection test failed before media path validation could complete.'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.warning(message)
|
||||
}
|
||||
} catch (error) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = friendlyConnectionError(error)
|
||||
mediaPathTestMessage.value = message
|
||||
toast.error(message)
|
||||
} finally {
|
||||
busy.testingMediaPath = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveAndValidate() {
|
||||
if (hasActiveRun.value) {
|
||||
toast.error('Cannot modify source while a run is active')
|
||||
@@ -556,6 +835,7 @@ async function autoLoadPathPrefixes() {
|
||||
}
|
||||
|
||||
async function onReloadSuggestions() {
|
||||
await loadTargetUsers()
|
||||
await hydrateUserMappingsFromSuggestions(true)
|
||||
}
|
||||
|
||||
@@ -948,6 +1228,24 @@ function formatSourceCountLabel(key: string) {
|
||||
.trim()
|
||||
.replace(/^./, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
interface SourceTypeCompatibility {
|
||||
testedVersions: string[]
|
||||
note: string
|
||||
}
|
||||
|
||||
const SOURCE_TYPE_COMPATIBILITY: Record<string, SourceTypeCompatibility> = {
|
||||
booklore: {
|
||||
testedVersions: ['v2.2.2'],
|
||||
note: 'Later versions are likely compatible but have not been verified. Run a dry-run first to confirm before committing data.',
|
||||
},
|
||||
grimmory: {
|
||||
testedVersions: ['v3.0.3'],
|
||||
note: 'Later versions are likely compatible but have not been verified. Run a dry-run first to confirm before committing data.',
|
||||
},
|
||||
}
|
||||
|
||||
const sourceTypeCompatibility = computed<SourceTypeCompatibility | null>(() => SOURCE_TYPE_COMPATIBILITY[sourceDraft.type] ?? null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1035,8 +1333,8 @@ function formatSourceCountLabel(key: string) {
|
||||
|
||||
<template v-else>
|
||||
<!-- Step 0: Source Connection -->
|
||||
<div v-if="currentStep === 0" class="space-y-5">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div v-if="currentStep === 0" class="space-y-3">
|
||||
<div class="grid gap-2 md:grid-cols-2">
|
||||
<label class="block">
|
||||
<span class="settings-hint">Source Type</span>
|
||||
<select v-model="sourceDraft.type" class="select-field mt-1 w-full" :disabled="hasActiveRun">
|
||||
@@ -1093,29 +1391,58 @@ function formatSourceCountLabel(key: string) {
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="settings-hint">Media Root Path</span>
|
||||
<input
|
||||
v-model="sourceDraft.mediaRootPath"
|
||||
class="input-field mt-1 w-full"
|
||||
placeholder="/data/booklore/media"
|
||||
:disabled="hasActiveRun"
|
||||
/>
|
||||
<p class="mt-1 text-xs" :class="sourceDraft.mediaRootPath.trim() ? 'text-muted-foreground' : 'text-amber-600'">
|
||||
{{
|
||||
sourceDraft.mediaRootPath.trim()
|
||||
? 'Book cover images will be imported from this directory.'
|
||||
: 'Required for cover import. Without this, book covers will not be migrated.'
|
||||
}}
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
v-model="sourceDraft.mediaRootPath"
|
||||
class="input-field w-full"
|
||||
placeholder="/data/booklore/media"
|
||||
:disabled="hasActiveRun"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy.testingMediaPath || hasActiveRun"
|
||||
class="inline-flex h-9 items-center rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
:class="
|
||||
mediaPathTestState === 'pass'
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20'
|
||||
: mediaPathTestState === 'fail'
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
|
||||
: 'border-border bg-background text-foreground hover:bg-muted'
|
||||
"
|
||||
@click="onTestMediaPath"
|
||||
>
|
||||
<Loader2 v-if="busy.testingMediaPath" class="size-3.5 animate-spin" />
|
||||
<span v-else>
|
||||
{{ mediaPathTestState === 'pass' ? 'Path OK' : mediaPathTestState === 'fail' ? 'Path Issue' : 'Test Path' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs" :class="mediaRootPathHint.className">{{ mediaRootPathHint.text }}</p>
|
||||
<p
|
||||
v-if="mediaPathTestMessage"
|
||||
class="mt-1 text-xs"
|
||||
:class="mediaPathTestState === 'pass' ? 'text-emerald-700' : 'text-amber-700'"
|
||||
>
|
||||
{{ mediaPathTestMessage }}
|
||||
</p>
|
||||
</label>
|
||||
<div class="block">
|
||||
<span class="settings-hint opacity-0 select-none">_</span>
|
||||
<label class="mt-1 flex h-9 cursor-pointer items-center gap-2">
|
||||
<div class="flex items-center">
|
||||
<label class="flex h-9 cursor-pointer items-center gap-2">
|
||||
<input v-model="sourceDraft.ssl" type="checkbox" class="size-4 rounded border-border" :disabled="hasActiveRun" />
|
||||
<span class="settings-hint">Use TLS/SSL</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceTypeCompatibility" class="rounded border border-border bg-muted/40 px-3.5 py-3 text-xs space-y-1">
|
||||
<p class="font-medium text-foreground">Tested compatibility</p>
|
||||
<p class="text-muted-foreground">
|
||||
Verified against:
|
||||
<span class="font-mono font-medium text-foreground">{{ sourceTypeCompatibility.testedVersions.join(', ') }}</span>
|
||||
</p>
|
||||
<p class="text-muted-foreground">{{ sourceTypeCompatibility.note }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="settings-btn-outline" :disabled="busy.testingSource || hasActiveRun" @click="onTestSource">
|
||||
<Loader2 v-if="busy.testingSource" class="size-3.5 animate-spin" />
|
||||
@@ -1154,10 +1481,10 @@ function formatSourceCountLabel(key: string) {
|
||||
<button
|
||||
v-if="source"
|
||||
class="text-xs text-primary underline-offset-2 hover:underline disabled:opacity-50"
|
||||
:disabled="busy.loadingSuggestions || hasActiveRun"
|
||||
:disabled="busy.loadingSuggestions || busy.loadingTargetUsers || hasActiveRun"
|
||||
@click="onReloadSuggestions"
|
||||
>
|
||||
<Loader2 v-if="busy.loadingSuggestions" class="size-3 animate-spin inline" />
|
||||
<Loader2 v-if="busy.loadingSuggestions || busy.loadingTargetUsers" class="size-3 animate-spin inline" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -1186,6 +1513,9 @@ function formatSourceCountLabel(key: string) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!busy.loadingTargetUsers && targetUsers.length === 0" class="text-xs text-amber-600">
|
||||
No active target users found. Create or re-enable a BookOrbit user before mapping.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1196,14 +1526,18 @@ function formatSourceCountLabel(key: string) {
|
||||
Path mappings translate source file paths to target paths so books can be matched by file location. Books are also matched by
|
||||
ISBN, file hash, and title/author regardless of path mappings.
|
||||
</p>
|
||||
<div v-for="(row, index) in pathMappings" :key="index" class="grid gap-2 grid-cols-[1fr_1fr_auto] items-center">
|
||||
<select v-model="row.sourcePrefix" class="select-field" :disabled="hasActiveRun">
|
||||
<div
|
||||
v-for="(row, index) in pathMappings"
|
||||
:key="index"
|
||||
class="grid gap-2 grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center"
|
||||
>
|
||||
<select v-model="row.sourcePrefix" class="select-field w-full min-w-0" :disabled="hasActiveRun">
|
||||
<option value="">
|
||||
{{ sourcePathPrefixes.length === 0 ? 'No prefixes found - validate source first' : 'Select source prefix...' }}
|
||||
</option>
|
||||
<option v-for="prefix in sourcePathPrefixes" :key="prefix" :value="prefix">{{ prefix }}</option>
|
||||
</select>
|
||||
<select v-model="row.targetPrefix" class="select-field" :disabled="hasActiveRun">
|
||||
<select v-model="row.targetPrefix" class="select-field w-full min-w-0" :disabled="hasActiveRun">
|
||||
<option value="">Select target folder...</option>
|
||||
<option v-for="folder in targetLibraryFolders" :key="folder.path" :value="folder.path">
|
||||
{{ folder.libraryName }} - {{ folder.path }}
|
||||
@@ -1253,9 +1587,17 @@ function formatSourceCountLabel(key: string) {
|
||||
</div>
|
||||
|
||||
<div v-if="duplicateMatches.length > 0" class="space-y-3 border-t border-border pt-3">
|
||||
<div>
|
||||
<p class="font-medium text-red-600">Duplicate target matches</p>
|
||||
<p class="text-xs text-muted-foreground">Multiple source books matched the same target book. Select which to use for each.</p>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-red-600">Resolve duplicate target matches</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
For each target book, choose one source book to keep. Recommended choices are preselected when there is a single strongest
|
||||
match.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Remaining groups:
|
||||
<span class="font-medium text-foreground">{{ duplicateGroupsRemaining }}</span>
|
||||
of {{ duplicateMatches.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
@@ -1263,22 +1605,38 @@ function formatSourceCountLabel(key: string) {
|
||||
:key="dup.targetBookId"
|
||||
class="rounded border border-border bg-background p-3 space-y-1.5"
|
||||
>
|
||||
<p class="text-xs font-medium">Target book #{{ dup.targetBookId }}</p>
|
||||
<p class="text-xs font-medium text-foreground">{{ targetBookLabel(dup.targetBookId) }}</p>
|
||||
<p class="text-[11px] text-muted-foreground">Target book #{{ dup.targetBookId }}</p>
|
||||
<label
|
||||
v-for="sourceId in dup.sourceBookIds"
|
||||
:key="sourceId"
|
||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer"
|
||||
class="flex items-center justify-between gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="`dup-${dup.targetBookId}`"
|
||||
:value="sourceId"
|
||||
:checked="duplicateResolutions.get(dup.targetBookId) === sourceId"
|
||||
class="accent-primary"
|
||||
@change="() => handleDuplicateSelection(dup.targetBookId, sourceId)"
|
||||
/>
|
||||
<span>{{ sourceId }}</span>
|
||||
<span class="text-muted-foreground">({{ dup.strategies[dup.sourceBookIds.indexOf(sourceId)] ?? 'unknown' }})</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
:name="`dup-${dup.targetBookId}`"
|
||||
:value="sourceId"
|
||||
:checked="duplicateResolutions.get(dup.targetBookId) === sourceId"
|
||||
class="accent-primary"
|
||||
@change="() => handleDuplicateSelection(dup.targetBookId, sourceId)"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate">{{ sourceBookLabel(dup, sourceId) }}</p>
|
||||
<p v-if="sourceBookSecondary(dup, sourceId)" class="truncate text-[11px] text-muted-foreground">
|
||||
{{ sourceBookSecondary(dup, sourceId) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="isRecommendedSourceBook(dup, sourceId)"
|
||||
class="inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700"
|
||||
>
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-[11px] whitespace-nowrap">{{
|
||||
friendlyMatchStrategy(strategyForSource(dup, sourceId))
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,27 +33,36 @@ const emit = defineEmits<{
|
||||
|
||||
const searchTerm = ref('')
|
||||
|
||||
const selectedUser = computed(() => props.users.find((u) => u.id === props.modelValue) ?? null)
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const q = searchTerm.value.toLowerCase().trim()
|
||||
if (!q) return props.users
|
||||
return props.users.filter((u) => u.username.toLowerCase().includes(q) || u.name.toLowerCase().includes(q))
|
||||
return props.users.filter((u) => `${u.username} ${u.name}`.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function handleSelect(user: User | null) {
|
||||
emit('update:modelValue', user?.id ?? null)
|
||||
function normalizeModelValue(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function displayValue(user: unknown): string {
|
||||
if (!user || typeof user !== 'object') return ''
|
||||
const u = user as User
|
||||
function handleSelect(value: unknown) {
|
||||
emit('update:modelValue', normalizeModelValue(value))
|
||||
}
|
||||
|
||||
function displayValue(value: unknown): string {
|
||||
const selectedId = normalizeModelValue(value)
|
||||
if (selectedId == null) return ''
|
||||
const u = props.users.find((user) => user.id === selectedId)
|
||||
if (!u) return ''
|
||||
return `${u.username} · ${u.name}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComboboxRoot :model-value="selectedUser" :ignore-filter="true" :disabled="disabled" @update:model-value="handleSelect">
|
||||
<ComboboxRoot :model-value="modelValue" :ignore-filter="true" :disabled="disabled" @update:model-value="handleSelect">
|
||||
<ComboboxAnchor
|
||||
class="inline-flex w-full min-w-48 items-center justify-between rounded-md border border-input bg-background text-sm text-foreground transition-colors focus-within:ring-2 focus-within:ring-primary/50 focus-within:border-primary disabled:opacity-50"
|
||||
>
|
||||
@@ -72,14 +81,14 @@ function displayValue(user: unknown): string {
|
||||
<ComboboxContent
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
class="z-50 max-h-60 w-[var(--reka-combobox-trigger-width)] overflow-hidden rounded-md border border-border bg-popover shadow-md"
|
||||
class="z-[90] max-h-60 w-[var(--reka-combobox-trigger-width)] overflow-hidden rounded-md border border-border bg-popover shadow-md"
|
||||
>
|
||||
<ComboboxViewport class="p-1">
|
||||
<ComboboxEmpty class="px-3 py-2 text-sm text-muted-foreground"> No users found </ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="user in filteredUsers"
|
||||
:key="user.id"
|
||||
:value="user"
|
||||
:value="user.id"
|
||||
class="relative flex cursor-pointer items-center rounded-sm px-3 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<ComboboxItemIndicator class="absolute right-2">
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useMigrationPolling } from './useMigrationPolling'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function makePolling(options?: { intervalMs?: number; pollFn?: () => Promise<void> }) {
|
||||
const runState = ref<string | undefined>(undefined)
|
||||
const pollFn = options?.pollFn ?? vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const result = useMigrationPolling({ runState, pollFn, intervalMs: options?.intervalMs ?? 1000 })
|
||||
return { runState, pollFn: pollFn as ReturnType<typeof vi.fn>, ...result }
|
||||
}
|
||||
|
||||
describe('useMigrationPolling', () => {
|
||||
describe('start/stop', () => {
|
||||
it('starts polling at the given interval', async () => {
|
||||
const { pollFn, start } = makePolling({ intervalMs: 500 })
|
||||
start()
|
||||
vi.advanceTimersByTime(1500)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('stop clears the interval and stops polling', async () => {
|
||||
const { pollFn, start, stop } = makePolling({ intervalMs: 500 })
|
||||
start()
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
stop()
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stop is a no-op when not running', () => {
|
||||
const { stop } = makePolling()
|
||||
expect(() => stop()).not.toThrow()
|
||||
})
|
||||
|
||||
it('calling start twice only runs one timer', async () => {
|
||||
const { pollFn, start } = makePolling({ intervalMs: 500 })
|
||||
start()
|
||||
start()
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('doPoll error handling', () => {
|
||||
it('sets pollingError to true and stops polling on pollFn rejection', async () => {
|
||||
const pollFn = vi.fn<() => Promise<void>>().mockRejectedValue(new Error('network error'))
|
||||
const { pollingError, start } = makePolling({ pollFn, intervalMs: 500 })
|
||||
start()
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(pollingError.value).toBe(true)
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clears pollingError on successful poll', async () => {
|
||||
const pollFn = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const { pollingError, start } = makePolling({ pollFn, intervalMs: 500 })
|
||||
start()
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
expect(pollingError.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retry', () => {
|
||||
it('clears pollingError and starts polling again', async () => {
|
||||
const pollFn = vi.fn<() => Promise<void>>()
|
||||
pollFn.mockRejectedValueOnce(new Error('fail')).mockResolvedValue(undefined)
|
||||
const { pollingError, start, retry } = makePolling({ pollFn, intervalMs: 500 })
|
||||
|
||||
start()
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(pollingError.value).toBe(true)
|
||||
|
||||
retry()
|
||||
await Promise.resolve()
|
||||
expect(pollingError.value).toBe(false)
|
||||
})
|
||||
|
||||
it('calls pollFn immediately on retry', async () => {
|
||||
const pollFn = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
|
||||
const { retry } = makePolling({ pollFn, intervalMs: 1000 })
|
||||
retry()
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('watcher on runState', () => {
|
||||
it('starts polling when runState changes to running', async () => {
|
||||
const { runState, pollFn } = makePolling({ intervalMs: 500 })
|
||||
runState.value = 'running'
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('stops polling when runState changes away from running', async () => {
|
||||
const { runState, pollFn } = makePolling({ intervalMs: 500 })
|
||||
runState.value = 'running'
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
runState.value = 'completed'
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops polling for idle state', async () => {
|
||||
const { runState, pollFn } = makePolling({ intervalMs: 500 })
|
||||
runState.value = 'running'
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(500)
|
||||
await Promise.resolve()
|
||||
runState.value = 'idle'
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not start polling when runState is undefined', async () => {
|
||||
const { runState, pollFn } = makePolling({ intervalMs: 500 })
|
||||
runState.value = undefined
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(1000)
|
||||
await Promise.resolve()
|
||||
expect(pollFn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pollingError state', () => {
|
||||
it('starts with pollingError false', () => {
|
||||
const { pollingError } = makePolling()
|
||||
expect(pollingError.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getAccessToken: vi.fn<() => string | null>().mockReturnValue('test-token'),
|
||||
}))
|
||||
|
||||
type SocketEventHandler = (...args: unknown[]) => void
|
||||
|
||||
interface MockSocket {
|
||||
on: ReturnType<typeof vi.fn>
|
||||
emit: ReturnType<typeof vi.fn>
|
||||
_handlers: Record<string, SocketEventHandler>
|
||||
_trigger: (event: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
function createMockSocket(): MockSocket {
|
||||
const handlers: Record<string, SocketEventHandler> = {}
|
||||
const socket: MockSocket = {
|
||||
on: vi.fn<(event: string, handler: SocketEventHandler) => void>((event, handler) => {
|
||||
handlers[event] = handler
|
||||
}),
|
||||
emit: vi.fn<(...args: unknown[]) => void>(),
|
||||
_handlers: handlers,
|
||||
_trigger: (event: string, ...args: unknown[]) => handlers[event]?.(...args),
|
||||
}
|
||||
return socket
|
||||
}
|
||||
|
||||
let mockSocket: MockSocket
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
io: vi.fn<() => MockSocket>(() => mockSocket),
|
||||
}))
|
||||
|
||||
beforeEach(async () => {
|
||||
mockSocket = createMockSocket()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function importUseMigrationProgress() {
|
||||
const mod = await import('./useMigrationProgress')
|
||||
return mod.useMigrationProgress
|
||||
}
|
||||
|
||||
describe('useMigrationProgress', () => {
|
||||
describe('subscribeRun', () => {
|
||||
it('emits subscribe:run with the runId', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun } = useMigrationProgress()
|
||||
subscribeRun(42)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe:run', 42)
|
||||
})
|
||||
|
||||
it('emits subscribe:run for each runId subscribed', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun } = useMigrationProgress()
|
||||
subscribeRun(1)
|
||||
subscribeRun(2)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe:run', 1)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe:run', 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsubscribeRun', () => {
|
||||
it('removes the runId from the progress map', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun, unsubscribeRun, progressMap } = useMigrationProgress()
|
||||
|
||||
subscribeRun(10)
|
||||
mockSocket._trigger('migration:progress', {
|
||||
runId: 10,
|
||||
state: 'running',
|
||||
currentStage: null,
|
||||
totals: { processed: 5, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
})
|
||||
|
||||
expect(progressMap.value.has(10)).toBe(true)
|
||||
unsubscribeRun(10)
|
||||
expect(progressMap.value.has(10)).toBe(false)
|
||||
})
|
||||
|
||||
it('is safe to call for a runId that was never subscribed', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { unsubscribeRun, progressMap } = useMigrationProgress()
|
||||
expect(() => unsubscribeRun(999)).not.toThrow()
|
||||
expect(progressMap.value.has(999)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProgress', () => {
|
||||
it('returns undefined when no progress event received', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { getProgress } = useMigrationProgress()
|
||||
expect(getProgress(1)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the progress event after migration:progress is received', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun, getProgress } = useMigrationProgress()
|
||||
subscribeRun(7)
|
||||
const event = {
|
||||
runId: 7,
|
||||
state: 'running' as const,
|
||||
currentStage: null,
|
||||
totals: { processed: 10, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
}
|
||||
mockSocket._trigger('migration:progress', event)
|
||||
expect(getProgress(7)).toEqual(event)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunning', () => {
|
||||
it('returns false when no progress event received', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { isRunning } = useMigrationProgress()
|
||||
expect(isRunning(5)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when progress state is running', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun, isRunning } = useMigrationProgress()
|
||||
subscribeRun(5)
|
||||
mockSocket._trigger('migration:progress', {
|
||||
runId: 5,
|
||||
state: 'running',
|
||||
currentStage: null,
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
})
|
||||
expect(isRunning(5)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when progress state is completed', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun, isRunning } = useMigrationProgress()
|
||||
subscribeRun(5)
|
||||
mockSocket._trigger('migration:progress', {
|
||||
runId: 5,
|
||||
state: 'completed',
|
||||
currentStage: null,
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
})
|
||||
expect(isRunning(5)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('socket events', () => {
|
||||
it('clears progressMap on disconnect', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun, progressMap } = useMigrationProgress()
|
||||
subscribeRun(1)
|
||||
mockSocket._trigger('migration:progress', {
|
||||
runId: 1,
|
||||
state: 'running',
|
||||
currentStage: null,
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
})
|
||||
expect(progressMap.value.size).toBe(1)
|
||||
mockSocket._trigger('disconnect')
|
||||
expect(progressMap.value.size).toBe(0)
|
||||
})
|
||||
|
||||
it('re-subscribes all runs on reconnect', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun } = useMigrationProgress()
|
||||
subscribeRun(1)
|
||||
subscribeRun(2)
|
||||
mockSocket.emit.mockClear()
|
||||
mockSocket._trigger('connect')
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe:run', 1)
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith('subscribe:run', 2)
|
||||
})
|
||||
|
||||
it('overwrites previous progress event for the same runId', async () => {
|
||||
const useMigrationProgress = await importUseMigrationProgress()
|
||||
const { subscribeRun, getProgress } = useMigrationProgress()
|
||||
subscribeRun(3)
|
||||
const totals = { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 }
|
||||
mockSocket._trigger('migration:progress', { runId: 3, state: 'running', currentStage: null, totals: { ...totals, processed: 10 }, metrics: [] })
|
||||
mockSocket._trigger('migration:progress', { runId: 3, state: 'running', currentStage: null, totals: { ...totals, processed: 20 }, metrics: [] })
|
||||
expect(getProgress(3)?.totals.processed).toBe(20)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<(...args: Parameters<typeof import('@/lib/api').api>) => Promise<Response>>(),
|
||||
}))
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
listSupportedSourceTypes,
|
||||
getWorkflowState,
|
||||
listTargetUsers,
|
||||
listSourcePathPrefixes,
|
||||
listTargetLibraryFolders,
|
||||
createSource,
|
||||
testSource,
|
||||
validateSourceById,
|
||||
suggestUserMappings,
|
||||
validatePathMappings,
|
||||
createProfile,
|
||||
createDryRunPlan,
|
||||
resolveDuplicateMatches,
|
||||
startLiveRun,
|
||||
getRunProgress,
|
||||
getRunReport,
|
||||
exportRunReport,
|
||||
cancelRun,
|
||||
retryRun,
|
||||
} from './migration-api'
|
||||
|
||||
const mockApi = vi.mocked(api)
|
||||
|
||||
function okResponse(body: unknown, headers?: Record<string, string>): Response {
|
||||
const h = new Headers(headers)
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
headers: h,
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
function errorResponse(status: number, body: unknown): Response {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)),
|
||||
headers: new Headers(),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('expectJson helper', () => {
|
||||
it('returns parsed JSON on ok response', async () => {
|
||||
mockApi.mockResolvedValue(okResponse(['booklore']))
|
||||
const result = await listSupportedSourceTypes()
|
||||
expect(result).toEqual(['booklore'])
|
||||
})
|
||||
|
||||
it('throws error with message from payload on non-ok response', async () => {
|
||||
mockApi.mockResolvedValue(errorResponse(400, { message: 'Validation failed' }))
|
||||
await expect(listSupportedSourceTypes()).rejects.toThrow('Validation failed')
|
||||
})
|
||||
|
||||
it('throws fallback message when error payload has no message field', async () => {
|
||||
mockApi.mockResolvedValue(errorResponse(500, { error: 'some error' }))
|
||||
await expect(listSupportedSourceTypes()).rejects.toThrow('Failed to load supported migration source types')
|
||||
})
|
||||
|
||||
it('throws fallback message when error payload json parse fails', async () => {
|
||||
const res: Response = {
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: vi.fn<() => Promise<never>>().mockRejectedValue(new Error('parse error')),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue(''),
|
||||
headers: new Headers(),
|
||||
} as unknown as Response
|
||||
mockApi.mockResolvedValue(res)
|
||||
await expect(listSupportedSourceTypes()).rejects.toThrow('Failed to load supported migration source types')
|
||||
})
|
||||
|
||||
it('throws fallback message when payload.message is not a string', async () => {
|
||||
mockApi.mockResolvedValue(errorResponse(500, { message: 42 }))
|
||||
await expect(listSupportedSourceTypes()).rejects.toThrow('Failed to load supported migration source types')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listSupportedSourceTypes', () => {
|
||||
it('calls the supported-types endpoint', async () => {
|
||||
mockApi.mockResolvedValue(okResponse(['booklore']))
|
||||
await listSupportedSourceTypes()
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/supported-types')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getWorkflowState', () => {
|
||||
it('calls the state endpoint and returns workflow state', async () => {
|
||||
const state = { active: null, hasActiveRun: false }
|
||||
mockApi.mockResolvedValue(okResponse(state))
|
||||
const result = await getWorkflowState()
|
||||
expect(result).toEqual(state)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/state')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listTargetUsers', () => {
|
||||
it('calls the target-users endpoint', async () => {
|
||||
const users = [{ id: 1, username: 'alice', name: 'Alice', email: null }]
|
||||
mockApi.mockResolvedValue(okResponse(users))
|
||||
const result = await listTargetUsers()
|
||||
expect(result).toEqual(users)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/target-users')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listSourcePathPrefixes', () => {
|
||||
it('calls the path-prefixes endpoint with sourceId', async () => {
|
||||
mockApi.mockResolvedValue(okResponse({ prefixes: ['/books'] }))
|
||||
const result = await listSourcePathPrefixes(42)
|
||||
expect(result).toEqual({ prefixes: ['/books'] })
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/sources/42/path-prefixes')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listTargetLibraryFolders', () => {
|
||||
it('flattens libraries into folder entries', async () => {
|
||||
const libs = [
|
||||
{ id: 1, name: 'Fiction', folders: [{ id: 10, path: '/books/fiction' }] },
|
||||
{
|
||||
id: 2,
|
||||
name: 'Non-Fiction',
|
||||
folders: [
|
||||
{ id: 20, path: '/books/nonfiction' },
|
||||
{ id: 21, path: '/books/other' },
|
||||
],
|
||||
},
|
||||
]
|
||||
mockApi.mockResolvedValue(okResponse(libs))
|
||||
const result = await listTargetLibraryFolders()
|
||||
expect(result).toEqual([
|
||||
{ libraryName: 'Fiction', path: '/books/fiction' },
|
||||
{ libraryName: 'Non-Fiction', path: '/books/nonfiction' },
|
||||
{ libraryName: 'Non-Fiction', path: '/books/other' },
|
||||
])
|
||||
})
|
||||
|
||||
it('throws error when libraries request fails', async () => {
|
||||
mockApi.mockResolvedValue(errorResponse(500, {}))
|
||||
await expect(listTargetLibraryFolders()).rejects.toThrow('Failed to load libraries')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSource', () => {
|
||||
it('posts to sources endpoint and returns created source', async () => {
|
||||
const source = { id: 1, type: 'booklore', name: 'My Source' }
|
||||
mockApi.mockResolvedValue(okResponse(source))
|
||||
const result = await createSource({ type: 'booklore', name: 'My Source', connectionConfig: { host: 'localhost' } })
|
||||
expect(result).toEqual(source)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/sources', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('testSource', () => {
|
||||
it('posts to sources/test endpoint', async () => {
|
||||
mockApi.mockResolvedValue(okResponse({ ok: true }))
|
||||
const result = await testSource({ type: 'booklore', connectionConfig: { host: 'localhost' } })
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/sources/test', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSourceById', () => {
|
||||
it('posts to sources/:id/validate endpoint', async () => {
|
||||
mockApi.mockResolvedValue(okResponse({ ok: true, warnings: [] }))
|
||||
const result = await validateSourceById(5)
|
||||
expect(result).toEqual({ ok: true, warnings: [] })
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/sources/5/validate', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestUserMappings', () => {
|
||||
it('gets user mapping suggestions for a source', async () => {
|
||||
const payload = { sourceId: 1, generatedAt: 'now', suggestions: [] }
|
||||
mockApi.mockResolvedValue(okResponse(payload))
|
||||
const result = await suggestUserMappings(1)
|
||||
expect(result).toEqual(payload)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/sources/1/user-mapping-suggestions')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validatePathMappings', () => {
|
||||
it('posts path mappings for validation', async () => {
|
||||
const response = { sourceId: 1, validatedAt: 'now', pathMappingsHash: 'abc', persistedProfileId: null, summary: {}, mappings: [] }
|
||||
mockApi.mockResolvedValue(okResponse(response))
|
||||
const result = await validatePathMappings(1, { pathMappings: [{ sourcePrefix: '/src', targetPrefix: '/tgt' }] })
|
||||
expect(result).toEqual(response)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/sources/1/path-mappings/validate', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createProfile', () => {
|
||||
it('posts to profiles endpoint', async () => {
|
||||
const profile = { id: 1, name: 'Test Profile', sourceId: 1 }
|
||||
mockApi.mockResolvedValue(okResponse(profile))
|
||||
const result = await createProfile({ sourceId: 1, name: 'Test Profile', userMappings: [] })
|
||||
expect(result).toEqual(profile)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/profiles', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDryRunPlan', () => {
|
||||
it('posts to plans/dry-run endpoint', async () => {
|
||||
const plan = { id: 1, profileId: 1 }
|
||||
mockApi.mockResolvedValue(okResponse(plan))
|
||||
const result = await createDryRunPlan({ profileId: 1 })
|
||||
expect(result).toEqual(plan)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/plans/dry-run', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveDuplicateMatches', () => {
|
||||
it('posts resolutions to plan artifact endpoint', async () => {
|
||||
const artifact = { id: 7 }
|
||||
mockApi.mockResolvedValue(okResponse(artifact))
|
||||
const result = await resolveDuplicateMatches(7, [{ targetBookId: 100, selectedSourceBookId: 's1' }])
|
||||
expect(result).toEqual(artifact)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/plans/7/resolve-duplicates', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('startLiveRun', () => {
|
||||
it('posts to runs/live endpoint', async () => {
|
||||
const run = { id: 99, state: 'running' }
|
||||
mockApi.mockResolvedValue(okResponse(run))
|
||||
const result = await startLiveRun({ planArtifactId: 7 })
|
||||
expect(result).toEqual(run)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/runs/live', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRunProgress', () => {
|
||||
it('fetches progress for a run', async () => {
|
||||
const progress = { run: { id: 1 }, totals: {}, metrics: [] }
|
||||
mockApi.mockResolvedValue(okResponse(progress))
|
||||
const result = await getRunProgress(1)
|
||||
expect(result).toEqual(progress)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/runs/1/progress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRunReport', () => {
|
||||
it('fetches report for a run', async () => {
|
||||
const report = { run: { id: 1 }, totals: {}, metrics: [], plan: null, summary: null, details: {} }
|
||||
mockApi.mockResolvedValue(okResponse(report))
|
||||
const result = await getRunReport(1)
|
||||
expect(result).toEqual(report)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/runs/1/report')
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportRunReport', () => {
|
||||
it('returns file content with disposition filename for json format', async () => {
|
||||
const res = {
|
||||
ok: true,
|
||||
headers: new Headers({ 'Content-Disposition': 'attachment; filename="report.json"', 'Content-Type': 'application/json' }),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue('{"foo":"bar"}'),
|
||||
} as unknown as Response
|
||||
mockApi.mockResolvedValue(res)
|
||||
const result = await exportRunReport(1, 'json')
|
||||
expect(result.fileName).toBe('report.json')
|
||||
expect(result.format).toBe('json')
|
||||
expect(result.content).toBe('{"foo":"bar"}')
|
||||
expect(result.contentType).toBe('application/json')
|
||||
})
|
||||
|
||||
it('uses fallback filename when Content-Disposition is absent', async () => {
|
||||
const res = {
|
||||
ok: true,
|
||||
headers: new Headers({}),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue('a,b'),
|
||||
} as unknown as Response
|
||||
mockApi.mockResolvedValue(res)
|
||||
const result = await exportRunReport(3, 'csv')
|
||||
expect(result.fileName).toBe('migration-run-3-report.csv')
|
||||
expect(result.contentType).toBe('text/csv')
|
||||
})
|
||||
|
||||
it('throws error with body text when response is not ok', async () => {
|
||||
const res = {
|
||||
ok: false,
|
||||
headers: new Headers(),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue('Export failed'),
|
||||
} as unknown as Response
|
||||
mockApi.mockResolvedValue(res)
|
||||
await expect(exportRunReport(1, 'json')).rejects.toThrow('Export failed')
|
||||
})
|
||||
|
||||
it('throws fallback error when response is not ok and body is empty', async () => {
|
||||
const res = {
|
||||
ok: false,
|
||||
headers: new Headers(),
|
||||
text: vi.fn<() => Promise<string>>().mockResolvedValue(''),
|
||||
} as unknown as Response
|
||||
mockApi.mockResolvedValue(res)
|
||||
await expect(exportRunReport(1, 'csv')).rejects.toThrow('Failed to export migration report')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelRun', () => {
|
||||
it('posts to cancel endpoint', async () => {
|
||||
const run = { id: 1, state: 'cancelled' }
|
||||
mockApi.mockResolvedValue(okResponse(run))
|
||||
const result = await cancelRun(1)
|
||||
expect(result).toEqual(run)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/runs/1/cancel', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryRun', () => {
|
||||
it('posts to retry endpoint', async () => {
|
||||
const run = { id: 1, state: 'running' }
|
||||
mockApi.mockResolvedValue(okResponse(run))
|
||||
const result = await retryRun(1)
|
||||
expect(result).toEqual(run)
|
||||
expect(mockApi).toHaveBeenCalledWith('/api/v1/migration/runs/1/retry', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import MigrationStepper from '@/features/migration/components/MigrationStepper.vue'
|
||||
import SearchableUserSelect from '@/features/migration/components/SearchableUserSelect.vue'
|
||||
import type { StepDefinition } from '@/features/migration/components/MigrationStepper.vue'
|
||||
import { api } from '@/lib/api'
|
||||
import {
|
||||
cancelRun,
|
||||
createDryRunPlan,
|
||||
@@ -71,6 +72,8 @@ const supportedTypes = ref<string[]>([])
|
||||
const targetUsers = ref<TargetUser[]>([])
|
||||
const targetLibraryFolders = ref<Array<{ libraryName: string; path: string }>>([])
|
||||
const sourcePathPrefixes = ref<string[]>([])
|
||||
const mediaPathTestState = ref<'idle' | 'pass' | 'fail'>('idle')
|
||||
const mediaPathTestMessage = ref<string | null>(null)
|
||||
|
||||
const workflowState = ref<MigrationWorkflowState | null>(null)
|
||||
const runProgress = ref<MigrationRunProgress | null>(null)
|
||||
@@ -95,8 +98,10 @@ const sourceDraft = reactive({
|
||||
|
||||
const busy = reactive({
|
||||
testingSource: false,
|
||||
testingMediaPath: false,
|
||||
savingSource: false,
|
||||
loadingSuggestions: false,
|
||||
loadingTargetUsers: false,
|
||||
savingProfile: false,
|
||||
dryRun: false,
|
||||
startingRun: false,
|
||||
@@ -119,6 +124,25 @@ const hasActiveRun = computed(() => workflowState.value?.hasActiveRun === true)
|
||||
const sourceCapabilities = computed<MigrationSourceCapabilities | null>(() => source.value?.capabilities ?? null)
|
||||
const sourceValidationWarnings = computed(() => sourceCapabilities.value?.warnings ?? [])
|
||||
const sourceRowCounts = computed(() => Object.entries(sourceCapabilities.value?.counts ?? {}).sort(([left], [right]) => left.localeCompare(right)))
|
||||
const mediaRootPathHint = computed(() => {
|
||||
const path = sourceDraft.mediaRootPath.trim()
|
||||
if (!path) {
|
||||
return {
|
||||
className: 'text-amber-600',
|
||||
text: 'Required for migrating book covers.',
|
||||
}
|
||||
}
|
||||
if (!path.startsWith('/')) {
|
||||
return {
|
||||
className: 'text-amber-600',
|
||||
text: 'Use an absolute path (for example: /books). Relative paths are not supported.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
className: 'text-muted-foreground',
|
||||
text: 'Cover import path configured. Use Test Connection to verify access and Booklore images folder structure.',
|
||||
}
|
||||
})
|
||||
|
||||
const preflight = computed(() => {
|
||||
const issues: string[] = []
|
||||
@@ -189,11 +213,31 @@ const activeStepIndex = computed(() => {
|
||||
const currentStep = ref(0)
|
||||
|
||||
watch(activeStepIndex, (newVal) => {
|
||||
const shouldHoldSourceStep = busy.savingSource && currentStep.value === 0 && newVal === 1
|
||||
const shouldHoldMappingsStep = busy.savingProfile && currentStep.value === 1 && newVal === 2
|
||||
const shouldHoldDryRunStep = busy.dryRun && currentStep.value === 2 && newVal === 3
|
||||
if (shouldHoldSourceStep || shouldHoldMappingsStep || shouldHoldDryRunStep) return
|
||||
if (newVal > currentStep.value) {
|
||||
currentStep.value = newVal
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [
|
||||
sourceDraft.mediaRootPath,
|
||||
sourceDraft.host,
|
||||
sourceDraft.port,
|
||||
sourceDraft.user,
|
||||
sourceDraft.password,
|
||||
sourceDraft.database,
|
||||
sourceDraft.ssl,
|
||||
],
|
||||
() => {
|
||||
mediaPathTestState.value = 'idle'
|
||||
mediaPathTestMessage.value = null
|
||||
},
|
||||
)
|
||||
|
||||
function onStepClick(index: number) {
|
||||
currentStep.value = index
|
||||
}
|
||||
@@ -247,6 +291,13 @@ interface DuplicateBookMatch {
|
||||
targetBookId: number
|
||||
sourceBookIds: string[]
|
||||
strategies: string[]
|
||||
sourceCandidates?: Array<{
|
||||
sourceBookId: string
|
||||
title: string | null
|
||||
author: string | null
|
||||
filePath: string | null
|
||||
strategy: string
|
||||
}>
|
||||
reason: string
|
||||
}
|
||||
|
||||
@@ -258,6 +309,131 @@ const duplicateMatches = computed<DuplicateBookMatch[]>(() => {
|
||||
})
|
||||
|
||||
const duplicateResolutions = ref<Map<number, string>>(new Map())
|
||||
const duplicateTargetBookLabels = ref<Map<number, string>>(new Map())
|
||||
const duplicateGroupsRemaining = computed(() => duplicateMatches.value.filter((dup) => !duplicateResolutions.value.has(dup.targetBookId)).length)
|
||||
|
||||
const STRATEGY_PRIORITY: Record<string, number> = {
|
||||
isbn: 40,
|
||||
file_hash: 30,
|
||||
path_mapping: 20,
|
||||
title_author: 10,
|
||||
}
|
||||
|
||||
function strategyForSource(dup: DuplicateBookMatch, sourceId: string): string | null {
|
||||
const candidateStrategy = dup.sourceCandidates?.find((candidate) => candidate.sourceBookId === sourceId)?.strategy
|
||||
if (typeof candidateStrategy === 'string' && candidateStrategy.length > 0) return candidateStrategy
|
||||
const index = dup.sourceBookIds.indexOf(sourceId)
|
||||
if (index < 0) return null
|
||||
const strategy = dup.strategies[index]
|
||||
return typeof strategy === 'string' ? strategy : null
|
||||
}
|
||||
|
||||
function sourceBookLabel(dup: DuplicateBookMatch, sourceId: string): string {
|
||||
const candidate = dup.sourceCandidates?.find((row) => row.sourceBookId === sourceId)
|
||||
const title = candidate?.title?.trim()
|
||||
if (title) return title
|
||||
return `Source record ID #${sourceId}`
|
||||
}
|
||||
|
||||
function sourceBookSecondary(dup: DuplicateBookMatch, sourceId: string): string | null {
|
||||
const candidate = dup.sourceCandidates?.find((row) => row.sourceBookId === sourceId)
|
||||
const author = candidate?.author?.trim()
|
||||
if (author) return `by ${author} (ID: ${sourceId})`
|
||||
const filePath = candidate?.filePath?.trim()
|
||||
if (filePath) return `${filePath} (ID: ${sourceId})`
|
||||
return `Booklore source ID: ${sourceId}`
|
||||
}
|
||||
|
||||
function recommendedSourceBookId(dup: DuplicateBookMatch): string | null {
|
||||
let bestScore = Number.NEGATIVE_INFINITY
|
||||
let bestSourceId: string | null = null
|
||||
let tie = false
|
||||
|
||||
for (const sourceId of dup.sourceBookIds) {
|
||||
const strategy = strategyForSource(dup, sourceId)
|
||||
const score = strategy ? (STRATEGY_PRIORITY[strategy] ?? 0) : 0
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestSourceId = sourceId
|
||||
tie = false
|
||||
continue
|
||||
}
|
||||
if (score === bestScore) tie = true
|
||||
}
|
||||
|
||||
if (!bestSourceId || tie) return null
|
||||
return bestSourceId
|
||||
}
|
||||
|
||||
function isRecommendedSourceBook(dup: DuplicateBookMatch, sourceId: string): boolean {
|
||||
return recommendedSourceBookId(dup) === sourceId
|
||||
}
|
||||
|
||||
function targetBookLabel(targetBookId: number): string {
|
||||
return duplicateTargetBookLabels.value.get(targetBookId) ?? `Library book #${targetBookId}`
|
||||
}
|
||||
|
||||
function pruneDuplicateResolutions() {
|
||||
const activeTargetIds = new Set(duplicateMatches.value.map((dup) => dup.targetBookId))
|
||||
const keptResolutions = new Map<number, string>()
|
||||
const keptLabels = new Map<number, string>()
|
||||
|
||||
for (const [targetBookId, sourceBookId] of duplicateResolutions.value) {
|
||||
if (activeTargetIds.has(targetBookId)) keptResolutions.set(targetBookId, sourceBookId)
|
||||
}
|
||||
for (const [targetBookId, label] of duplicateTargetBookLabels.value) {
|
||||
if (activeTargetIds.has(targetBookId)) keptLabels.set(targetBookId, label)
|
||||
}
|
||||
|
||||
duplicateResolutions.value = keptResolutions
|
||||
duplicateTargetBookLabels.value = keptLabels
|
||||
}
|
||||
|
||||
function applyRecommendedDuplicateSelections() {
|
||||
const next = new Map(duplicateResolutions.value)
|
||||
for (const dup of duplicateMatches.value) {
|
||||
if (next.has(dup.targetBookId)) continue
|
||||
const recommendedSource = recommendedSourceBookId(dup)
|
||||
if (!recommendedSource) continue
|
||||
next.set(dup.targetBookId, recommendedSource)
|
||||
}
|
||||
duplicateResolutions.value = next
|
||||
}
|
||||
|
||||
async function loadDuplicateTargetBookLabels() {
|
||||
const missingTargetIds = [...new Set(duplicateMatches.value.map((dup) => dup.targetBookId))].filter(
|
||||
(targetBookId) => !duplicateTargetBookLabels.value.has(targetBookId),
|
||||
)
|
||||
if (missingTargetIds.length === 0) return
|
||||
|
||||
const rows = await Promise.all(
|
||||
missingTargetIds.map(async (targetBookId) => {
|
||||
try {
|
||||
const response = await api(`/api/v1/books/${targetBookId}`)
|
||||
if (!response.ok) return [targetBookId, `Library book #${targetBookId}`] as const
|
||||
const payload = asRecord(await response.json())
|
||||
return [targetBookId, asString(payload.title) ?? `Library book #${targetBookId}`] as const
|
||||
} catch {
|
||||
return [targetBookId, `Library book #${targetBookId}`] as const
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const next = new Map(duplicateTargetBookLabels.value)
|
||||
for (const [targetBookId, label] of rows) next.set(targetBookId, label)
|
||||
duplicateTargetBookLabels.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
duplicateMatches,
|
||||
() => {
|
||||
pruneDuplicateResolutions()
|
||||
applyRecommendedDuplicateSelections()
|
||||
void loadDuplicateTargetBookLabels()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const reportData = computed(() => {
|
||||
const metrics = runReport.value?.metrics ?? runProgress.value?.metrics ?? []
|
||||
@@ -370,10 +546,7 @@ onMounted(async () => {
|
||||
async function initialize() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [typesRes, usersRes, foldersRes] = await Promise.all([listSupportedSourceTypes(), listTargetUsers(), listTargetLibraryFolders()])
|
||||
supportedTypes.value = typesRes
|
||||
targetUsers.value = usersRes
|
||||
targetLibraryFolders.value = foldersRes
|
||||
await Promise.all([loadSupportedTypes(), loadTargetUsers(), loadTargetLibraryFolders()])
|
||||
await refreshWorkflowState()
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, 'Failed to initialize migration settings'))
|
||||
@@ -382,12 +555,43 @@ async function initialize() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSupportedTypes() {
|
||||
try {
|
||||
supportedTypes.value = await listSupportedSourceTypes()
|
||||
} catch (error) {
|
||||
supportedTypes.value = []
|
||||
toast.error(getErrorMessage(error, 'Failed to load supported migration source types'))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTargetUsers() {
|
||||
busy.loadingTargetUsers = true
|
||||
try {
|
||||
targetUsers.value = await listTargetUsers()
|
||||
} catch (error) {
|
||||
targetUsers.value = []
|
||||
toast.error(getErrorMessage(error, 'Failed to load target users'))
|
||||
} finally {
|
||||
busy.loadingTargetUsers = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTargetLibraryFolders() {
|
||||
try {
|
||||
targetLibraryFolders.value = await listTargetLibraryFolders()
|
||||
} catch (error) {
|
||||
targetLibraryFolders.value = []
|
||||
toast.error(getErrorMessage(error, 'Failed to load target library folders'))
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWorkflowState() {
|
||||
workflowState.value = await getWorkflowState()
|
||||
hydrateSourceDraft()
|
||||
hydratePathMappings()
|
||||
hydrateUserMappingsFromProfile()
|
||||
if (source.value?.lastValidatedAt) {
|
||||
if (targetUsers.value.length === 0) await loadTargetUsers()
|
||||
await hydrateUserMappingsFromSuggestions(false)
|
||||
await autoLoadPathPrefixes()
|
||||
}
|
||||
@@ -439,11 +643,12 @@ async function hydrateUserMappingsFromSuggestions(showSuccessToast: boolean) {
|
||||
const response = await suggestUserMappings(currentSource.id)
|
||||
suggestionsLoadedAt.value = response.generatedAt
|
||||
const savedMappings = new Map((profile.value?.userMappings ?? []).map((row) => [row.sourceUserId, row.targetUserId]))
|
||||
const fallbackSingleTargetUserId = targetUsers.value.length === 1 ? (targetUsers.value[0]?.id ?? null) : null
|
||||
|
||||
userMappings.value = response.suggestions.map((row) => ({
|
||||
sourceUserId: row.sourceUserId,
|
||||
username: row.username,
|
||||
targetUserId: savedMappings.get(row.sourceUserId) ?? row.suggestedTargetUserId,
|
||||
targetUserId: savedMappings.get(row.sourceUserId) ?? row.suggestedTargetUserId ?? fallbackSingleTargetUserId,
|
||||
}))
|
||||
|
||||
if (showSuccessToast) {
|
||||
@@ -478,6 +683,14 @@ function hasValidSourceDraft() {
|
||||
return !!sourceDraft.name.trim() && !!sourceDraft.host.trim() && !!sourceDraft.user.trim() && !!sourceDraft.database.trim()
|
||||
}
|
||||
|
||||
function extractWarningStrings(result: Record<string, unknown>): string[] {
|
||||
return Array.isArray(result.warnings) ? (result.warnings as unknown[]).filter((w): w is string => typeof w === 'string') : []
|
||||
}
|
||||
|
||||
function extractMediaPathWarnings(result: Record<string, unknown>): string[] {
|
||||
return extractWarningStrings(result).filter((warning) => /mediarootpath/i.test(warning))
|
||||
}
|
||||
|
||||
async function onTestSource() {
|
||||
if (!hasValidSourceDraft()) {
|
||||
toast.error('Host, user, database, and source name are required')
|
||||
@@ -488,8 +701,17 @@ async function onTestSource() {
|
||||
try {
|
||||
const result = await testSource({ type: sourceDraft.type, connectionConfig: buildSourceConnectionConfig() })
|
||||
const missing = Array.isArray(result.missingTables) ? result.missingTables.length : 0
|
||||
const warnings = extractWarningStrings(result)
|
||||
if (result.ok === true) {
|
||||
toast.success('Connection test passed')
|
||||
if (warnings.length > 0) {
|
||||
const mediaPathWarning = warnings.find((warning) => /mediarootpath/i.test(warning))
|
||||
const highlightedWarning = mediaPathWarning ?? warnings[0] ?? 'Connection test passed with warnings'
|
||||
toast.warning(
|
||||
warnings.length === 1 ? highlightedWarning : `Connection test passed with ${warnings.length} warning(s). First: ${highlightedWarning}`,
|
||||
)
|
||||
} else {
|
||||
toast.success('Connection test passed')
|
||||
}
|
||||
} else {
|
||||
toast.warning(`Connection ok, but ${missing} required table(s) are missing`)
|
||||
}
|
||||
@@ -500,6 +722,60 @@ async function onTestSource() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onTestMediaPath() {
|
||||
if (hasActiveRun.value) {
|
||||
toast.error('Cannot test media path while a run is active')
|
||||
return
|
||||
}
|
||||
if (!hasValidSourceDraft()) {
|
||||
toast.error('Host, user, database, and source name are required')
|
||||
return
|
||||
}
|
||||
const mediaRootPath = sourceDraft.mediaRootPath.trim()
|
||||
if (!mediaRootPath) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = 'Media Root Path is required for cover import.'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
if (!mediaRootPath.startsWith('/')) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = 'Use an absolute path (for example: /books).'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
|
||||
busy.testingMediaPath = true
|
||||
try {
|
||||
const result = await testSource({ type: sourceDraft.type, connectionConfig: buildSourceConnectionConfig() })
|
||||
const mediaWarnings = extractMediaPathWarnings(result)
|
||||
if (result.ok === true && mediaWarnings.length === 0) {
|
||||
mediaPathTestState.value = 'pass'
|
||||
mediaPathTestMessage.value = 'Media path verified.'
|
||||
toast.success('Media path is valid and readable')
|
||||
} else if (mediaWarnings.length > 0) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = mediaWarnings[0] ?? 'Media path validation failed.'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.warning(message)
|
||||
} else {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = 'Connection test failed before media path validation could complete.'
|
||||
mediaPathTestMessage.value = message
|
||||
toast.warning(message)
|
||||
}
|
||||
} catch (error) {
|
||||
mediaPathTestState.value = 'fail'
|
||||
const message = friendlyConnectionError(error)
|
||||
mediaPathTestMessage.value = message
|
||||
toast.error(message)
|
||||
} finally {
|
||||
busy.testingMediaPath = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveAndValidate() {
|
||||
if (hasActiveRun.value) {
|
||||
toast.error('Cannot modify source while a run is active')
|
||||
@@ -546,6 +822,7 @@ async function autoLoadPathPrefixes() {
|
||||
}
|
||||
|
||||
async function onReloadSuggestions() {
|
||||
await loadTargetUsers()
|
||||
await hydrateUserMappingsFromSuggestions(true)
|
||||
}
|
||||
|
||||
@@ -1014,8 +1291,8 @@ function formatSourceCountLabel(key: string) {
|
||||
<!-- Card body -->
|
||||
<div class="px-6 py-6">
|
||||
<!-- Step 0: Source Connection -->
|
||||
<div v-if="currentStep === 0" class="space-y-5">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div v-if="currentStep === 0" class="space-y-3">
|
||||
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label class="block">
|
||||
<span class="settings-hint">Source Type</span>
|
||||
<select v-model="sourceDraft.type" class="select-field mt-1 w-full" :disabled="hasActiveRun">
|
||||
@@ -1065,23 +1342,34 @@ function formatSourceCountLabel(key: string) {
|
||||
</label>
|
||||
<label class="block md:col-span-2 xl:col-span-2">
|
||||
<span class="settings-hint">Media Root Path</span>
|
||||
<input
|
||||
v-model="sourceDraft.mediaRootPath"
|
||||
class="input-field mt-1 w-full"
|
||||
placeholder="/data/booklore/media"
|
||||
:disabled="hasActiveRun"
|
||||
/>
|
||||
<p class="mt-1 text-xs" :class="sourceDraft.mediaRootPath.trim() ? 'text-muted-foreground' : 'text-amber-600'">
|
||||
{{
|
||||
sourceDraft.mediaRootPath.trim()
|
||||
? 'Book cover images will be imported from this directory.'
|
||||
: 'Required for cover import. Without this, book covers will not be migrated.'
|
||||
}}
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<input v-model="sourceDraft.mediaRootPath" class="input-field w-full" placeholder="/data/booklore/media" :disabled="hasActiveRun" />
|
||||
<button
|
||||
type="button"
|
||||
:disabled="busy.testingMediaPath || hasActiveRun"
|
||||
class="inline-flex h-9 items-center rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
|
||||
:class="
|
||||
mediaPathTestState === 'pass'
|
||||
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20'
|
||||
: mediaPathTestState === 'fail'
|
||||
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
|
||||
: 'border-border bg-background text-foreground hover:bg-muted'
|
||||
"
|
||||
@click="onTestMediaPath"
|
||||
>
|
||||
<Loader2 v-if="busy.testingMediaPath" class="size-3.5 animate-spin" />
|
||||
<span v-else>
|
||||
{{ mediaPathTestState === 'pass' ? 'Path OK' : mediaPathTestState === 'fail' ? 'Path Issue' : 'Test Path' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs" :class="mediaRootPathHint.className">{{ mediaRootPathHint.text }}</p>
|
||||
<p v-if="mediaPathTestMessage" class="mt-1 text-xs" :class="mediaPathTestState === 'pass' ? 'text-emerald-700' : 'text-amber-700'">
|
||||
{{ mediaPathTestMessage }}
|
||||
</p>
|
||||
</label>
|
||||
<div class="block">
|
||||
<span class="settings-hint opacity-0 select-none">_</span>
|
||||
<label class="mt-1 flex h-9 cursor-pointer items-center gap-2">
|
||||
<div class="flex items-center">
|
||||
<label class="flex h-9 cursor-pointer items-center gap-2">
|
||||
<input v-model="sourceDraft.ssl" type="checkbox" class="size-4 rounded border-border" :disabled="hasActiveRun" />
|
||||
<span class="settings-hint">Use TLS/SSL</span>
|
||||
</label>
|
||||
@@ -1126,10 +1414,10 @@ function formatSourceCountLabel(key: string) {
|
||||
<button
|
||||
v-if="source"
|
||||
class="text-xs text-primary underline-offset-2 hover:underline disabled:opacity-50"
|
||||
:disabled="busy.loadingSuggestions || hasActiveRun"
|
||||
:disabled="busy.loadingSuggestions || busy.loadingTargetUsers || hasActiveRun"
|
||||
@click="onReloadSuggestions"
|
||||
>
|
||||
<Loader2 v-if="busy.loadingSuggestions" class="size-3 animate-spin inline" />
|
||||
<Loader2 v-if="busy.loadingSuggestions || busy.loadingTargetUsers" class="size-3 animate-spin inline" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -1158,6 +1446,9 @@ function formatSourceCountLabel(key: string) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="!busy.loadingTargetUsers && targetUsers.length === 0" class="text-xs text-amber-600">
|
||||
No active target users found. Create or re-enable a BookOrbit user before mapping.
|
||||
</p>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1172,15 +1463,15 @@ function formatSourceCountLabel(key: string) {
|
||||
included in the migration.
|
||||
</p>
|
||||
|
||||
<div v-for="(row, index) in pathMappings" :key="index" class="grid gap-2 md:grid-cols-[1fr_1fr_auto] items-center">
|
||||
<select v-model="row.sourcePrefix" class="select-field" :disabled="hasActiveRun">
|
||||
<div v-for="(row, index) in pathMappings" :key="index" class="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center">
|
||||
<select v-model="row.sourcePrefix" class="select-field w-full min-w-0" :disabled="hasActiveRun">
|
||||
<option value="">
|
||||
{{ sourcePathPrefixes.length === 0 ? 'No prefixes found - validate source first' : 'Select source prefix...' }}
|
||||
</option>
|
||||
<option v-for="prefix in sourcePathPrefixes" :key="prefix" :value="prefix">{{ prefix }}</option>
|
||||
</select>
|
||||
|
||||
<select v-model="row.targetPrefix" class="select-field" :disabled="hasActiveRun">
|
||||
<select v-model="row.targetPrefix" class="select-field w-full min-w-0" :disabled="hasActiveRun">
|
||||
<option value="">Select target folder...</option>
|
||||
<option v-for="folder in targetLibraryFolders" :key="folder.path" :value="folder.path">
|
||||
{{ folder.libraryName }} - {{ folder.path }}
|
||||
@@ -1231,31 +1522,52 @@ function formatSourceCountLabel(key: string) {
|
||||
</div>
|
||||
|
||||
<div v-if="duplicateMatches.length > 0" class="space-y-3 border-t border-border pt-3">
|
||||
<div>
|
||||
<p class="font-medium text-red-600">Duplicate target matches</p>
|
||||
<div class="space-y-1">
|
||||
<p class="font-medium text-red-600">Resolve duplicate target matches</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Multiple source books matched the same target book. Select which source book to use for each target.
|
||||
For each target book, choose one source book to keep. Recommended choices are preselected when there is a single strongest match.
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Remaining groups:
|
||||
<span class="font-medium text-foreground">{{ duplicateGroupsRemaining }}</span>
|
||||
of {{ duplicateMatches.length }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="dup in duplicateMatches" :key="dup.targetBookId" class="rounded border border-border bg-background p-3 space-y-2">
|
||||
<p class="text-xs font-medium">Target book #{{ dup.targetBookId }}</p>
|
||||
<p class="text-xs font-medium text-foreground">{{ targetBookLabel(dup.targetBookId) }}</p>
|
||||
<p class="text-[11px] text-muted-foreground">Target book #{{ dup.targetBookId }}</p>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
v-for="sourceId in dup.sourceBookIds"
|
||||
:key="sourceId"
|
||||
class="flex items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer"
|
||||
class="flex items-center justify-between gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
:name="`dup-${dup.targetBookId}`"
|
||||
:value="sourceId"
|
||||
:checked="duplicateResolutions.get(dup.targetBookId) === sourceId"
|
||||
class="accent-primary"
|
||||
@change="() => handleDuplicateSelection(dup.targetBookId, sourceId)"
|
||||
/>
|
||||
<span>{{ sourceId }}</span>
|
||||
<span class="text-muted-foreground"> ({{ dup.strategies[dup.sourceBookIds.indexOf(sourceId)] ?? 'unknown' }}) </span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
:name="`dup-${dup.targetBookId}`"
|
||||
:value="sourceId"
|
||||
:checked="duplicateResolutions.get(dup.targetBookId) === sourceId"
|
||||
class="accent-primary"
|
||||
@change="() => handleDuplicateSelection(dup.targetBookId, sourceId)"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate">{{ sourceBookLabel(dup, sourceId) }}</p>
|
||||
<p v-if="sourceBookSecondary(dup, sourceId)" class="truncate text-[11px] text-muted-foreground">
|
||||
{{ sourceBookSecondary(dup, sourceId) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
v-if="isRecommendedSourceBook(dup, sourceId)"
|
||||
class="inline-flex items-center rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-700"
|
||||
>
|
||||
Recommended
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-[11px] whitespace-nowrap">{{
|
||||
friendlyMatchStrategy(strategyForSource(dup, sourceId))
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ vi.mock('mysql2/promise', () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
@@ -90,4 +91,23 @@ describe('BookloreConnector', () => {
|
||||
await expect(connector.countRows(conn as never, 'book_file')).resolves.toBe(0);
|
||||
await expect(connector.queryRows(conn as never, 'SELECT * FROM book WHERE id = ?', [1])).resolves.toEqual([{ id: 1 }]);
|
||||
});
|
||||
|
||||
it('maps access denied connection failures to a user-facing BadRequestException', async () => {
|
||||
const connector = new BookloreConnector();
|
||||
const createConnection = mysql.createConnection as unknown as ReturnType<typeof vi.fn>;
|
||||
createConnection.mockRejectedValue(Object.assign(new Error("Access denied for user 'booklore'"), { code: 'ER_ACCESS_DENIED_ERROR' }));
|
||||
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await connector.withConnection({ host: 'localhost', user: 'booklore', password: 'pw', database: 'booklore', ssl: false } as never, () =>
|
||||
Promise.resolve('ok'),
|
||||
);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||
const payload = (thrown as BadRequestException).getResponse() as { message?: string };
|
||||
expect(payload.message).toBe('Authentication failed. Check the username and password.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import mysql from 'mysql2/promise';
|
||||
import type { RowDataPacket } from 'mysql2';
|
||||
|
||||
@@ -12,19 +12,28 @@ const CONNECT_TIMEOUT_MS = 10_000;
|
||||
@Injectable()
|
||||
export class BookloreConnector {
|
||||
async withConnection<T>(config: BookloreConnectionConfig, fn: (conn: mysql.Connection) => Promise<T>): Promise<T> {
|
||||
const connection = await mysql.createConnection({
|
||||
host: config.host,
|
||||
port: config.port ?? 3306,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssl: config.ssl ? {} : undefined,
|
||||
supportBigNumbers: true,
|
||||
connectTimeout: CONNECT_TIMEOUT_MS,
|
||||
});
|
||||
let connection: mysql.Connection;
|
||||
try {
|
||||
connection = await mysql.createConnection({
|
||||
host: config.host,
|
||||
port: config.port ?? 3306,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssl: config.ssl ? {} : undefined,
|
||||
supportBigNumbers: true,
|
||||
connectTimeout: CONNECT_TIMEOUT_MS,
|
||||
});
|
||||
} catch (error) {
|
||||
throw toSourceConnectionException(error);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(connection);
|
||||
try {
|
||||
return await fn(connection);
|
||||
} catch (error) {
|
||||
throw toSourceConnectionException(error);
|
||||
}
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
@@ -70,3 +79,59 @@ export class BookloreConnector {
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
function toSourceConnectionException(error: unknown): unknown {
|
||||
if (error instanceof BadRequestException) return error;
|
||||
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
|
||||
const code = asErrorCode(error);
|
||||
const normalizedMessage = message.toLowerCase();
|
||||
|
||||
if (code === 'ER_ACCESS_DENIED_ERROR' || normalizedMessage.includes('access denied') || normalizedMessage.includes('authentication failed')) {
|
||||
return new BadRequestException('Authentication failed. Check the username and password.');
|
||||
}
|
||||
|
||||
if (code === 'ER_BAD_DB_ERROR' || normalizedMessage.includes('unknown database')) {
|
||||
return new BadRequestException('Database not found. Check the database name.');
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'ECONNREFUSED' ||
|
||||
code === 'ENOTFOUND' ||
|
||||
code === 'EHOSTUNREACH' ||
|
||||
code === 'EAI_AGAIN' ||
|
||||
normalizedMessage.includes('econnrefused') ||
|
||||
normalizedMessage.includes('enotfound') ||
|
||||
normalizedMessage.includes('ehostunreach')
|
||||
) {
|
||||
return new BadRequestException('Could not reach the database server. Check the host and port.');
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'ETIMEDOUT' ||
|
||||
code === 'PROTOCOL_SEQUENCE_TIMEOUT' ||
|
||||
normalizedMessage.includes('etimedout') ||
|
||||
normalizedMessage.includes('timed out') ||
|
||||
normalizedMessage.includes('timeout')
|
||||
) {
|
||||
return new BadRequestException('Connection timed out. Check host and firewall settings.');
|
||||
}
|
||||
|
||||
if (
|
||||
code === 'HANDSHAKE_SSL_ERROR' ||
|
||||
normalizedMessage.includes('ssl') ||
|
||||
normalizedMessage.includes('tls') ||
|
||||
normalizedMessage.includes('certificate')
|
||||
) {
|
||||
return new BadRequestException('SSL/TLS connection failed. Check TLS/SSL settings and certificates.');
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
function asErrorCode(error: unknown): string | null {
|
||||
if (!error || typeof error !== 'object' || !('code' in error)) return null;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
if (typeof code !== 'string' || code.trim().length === 0) return null;
|
||||
return code.trim().toUpperCase();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { access, stat } from 'fs/promises';
|
||||
import type mysql from 'mysql2/promise';
|
||||
import { join } from 'path';
|
||||
|
||||
import type {
|
||||
SourceAdapter,
|
||||
@@ -85,7 +88,7 @@ interface TableResolution {
|
||||
|
||||
@Injectable()
|
||||
export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionConfig> {
|
||||
readonly type = 'booklore';
|
||||
readonly type: string = 'booklore';
|
||||
|
||||
constructor(private readonly connector: BookloreConnector) {}
|
||||
|
||||
@@ -101,7 +104,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
|
||||
if (!resolved.bookFile) missingTables.push('book_file');
|
||||
|
||||
const counts = await this.collectCounts(conn, resolved);
|
||||
const warnings = this.buildWarnings(resolved, config);
|
||||
const warnings = await this.buildWarnings(resolved, config);
|
||||
|
||||
return {
|
||||
ok: missingTables.length === 0,
|
||||
@@ -208,7 +211,7 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
|
||||
return out;
|
||||
}
|
||||
|
||||
private buildWarnings(resolved: TableResolution, config: BookloreConnectionConfig): string[] {
|
||||
private async buildWarnings(resolved: TableResolution, config: BookloreConnectionConfig): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!resolved.bookMetadata) warnings.push('book_metadata table not found; metadata overlays will be limited');
|
||||
@@ -233,12 +236,72 @@ export class BookloreSourceAdapter implements SourceAdapter<BookloreConnectionCo
|
||||
) {
|
||||
warnings.push('Booklore viewer preference tables detected; reader preference migration is deferred');
|
||||
}
|
||||
if (!config.mediaRootPath) warnings.push('mediaRootPath not configured; book cover/thumbnail import disabled');
|
||||
if (!config.mediaRootPath) {
|
||||
warnings.push('mediaRootPath not configured; book cover/thumbnail import disabled');
|
||||
} else {
|
||||
warnings.push(...(await this.validateMediaRootPath(config.mediaRootPath)));
|
||||
}
|
||||
if (!config.password) warnings.push('No password set; connection may fail if the database requires authentication');
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private async validateMediaRootPath(mediaRootPath: string): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const rootState = await this.inspectDirectory(mediaRootPath);
|
||||
if (rootState === 'missing') {
|
||||
warnings.push(`mediaRootPath "${mediaRootPath}" does not exist on this server; book cover/thumbnail import will be skipped`);
|
||||
return warnings;
|
||||
}
|
||||
if (rootState === 'not_directory') {
|
||||
warnings.push(`mediaRootPath "${mediaRootPath}" is not a directory; book cover/thumbnail import will be skipped`);
|
||||
return warnings;
|
||||
}
|
||||
if (rootState === 'not_readable') {
|
||||
warnings.push(`mediaRootPath "${mediaRootPath}" is not readable by the BookOrbit process; book cover/thumbnail import may fail`);
|
||||
return warnings;
|
||||
}
|
||||
if (rootState === 'unknown') {
|
||||
warnings.push(`mediaRootPath "${mediaRootPath}" could not be verified; book cover/thumbnail import may fail`);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
const imagesPath = join(mediaRootPath, 'images');
|
||||
const imagesState = await this.inspectDirectory(imagesPath);
|
||||
if (imagesState === 'missing') {
|
||||
warnings.push(`mediaRootPath "${mediaRootPath}" is missing the "images" subdirectory expected by Booklore covers`);
|
||||
} else if (imagesState === 'not_directory') {
|
||||
warnings.push(`mediaRootPath "${imagesPath}" is not a directory; book cover/thumbnail import will be skipped`);
|
||||
} else if (imagesState === 'not_readable') {
|
||||
warnings.push(`mediaRootPath "${imagesPath}" is not readable by the BookOrbit process; book cover/thumbnail import may fail`);
|
||||
} else if (imagesState === 'unknown') {
|
||||
warnings.push(`mediaRootPath "${imagesPath}" could not be verified; book cover/thumbnail import may fail`);
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private async inspectDirectory(path: string): Promise<'ok' | 'missing' | 'not_directory' | 'not_readable' | 'unknown'> {
|
||||
try {
|
||||
const info = await stat(path);
|
||||
if (!info.isDirectory()) return 'not_directory';
|
||||
} catch (error) {
|
||||
if (isFsErrorCode(error, 'ENOENT')) return 'missing';
|
||||
if (isFsErrorCode(error, 'EACCES') || isFsErrorCode(error, 'EPERM')) return 'not_readable';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
await access(path, fsConstants.R_OK);
|
||||
return 'ok';
|
||||
} catch (error) {
|
||||
if (isFsErrorCode(error, 'ENOENT')) return 'missing';
|
||||
if (isFsErrorCode(error, 'EACCES') || isFsErrorCode(error, 'EPERM')) return 'not_readable';
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchUsers(conn: mysql.Connection, usersTable: string | null): Promise<SourceUser[]> {
|
||||
if (!usersTable) return [];
|
||||
|
||||
@@ -1127,3 +1190,7 @@ function toIso(value: unknown): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFsErrorCode(error: unknown, code: string): boolean {
|
||||
return !!error && typeof error === 'object' && 'code' in error && (error as { code?: unknown }).code === code;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BookloreSourceAdapter } from '../booklore/booklore-source.adapter';
|
||||
|
||||
@Injectable()
|
||||
export class GrimmorySourceAdapter extends BookloreSourceAdapter {
|
||||
override readonly type = 'grimmory';
|
||||
}
|
||||
@@ -10,22 +10,31 @@ describe('SourceAdapterRegistry', () => {
|
||||
snapshot: vi.fn(),
|
||||
exportData: vi.fn(),
|
||||
};
|
||||
const grimmoryAdapter = {
|
||||
type: 'grimmory',
|
||||
validate: vi.fn(),
|
||||
snapshot: vi.fn(),
|
||||
exportData: vi.fn(),
|
||||
};
|
||||
return {
|
||||
registry: new SourceAdapterRegistry(bookloreAdapter as never),
|
||||
registry: new SourceAdapterRegistry(bookloreAdapter as never, grimmoryAdapter as never),
|
||||
bookloreAdapter,
|
||||
grimmoryAdapter,
|
||||
};
|
||||
}
|
||||
|
||||
it('lists supported source types in sorted order', () => {
|
||||
const { registry } = makeRegistry();
|
||||
expect(registry.listTypes()).toEqual(['booklore']);
|
||||
expect(registry.listTypes()).toEqual(['booklore', 'grimmory']);
|
||||
});
|
||||
|
||||
it('retrieves adapter by normalized source type', () => {
|
||||
const { registry, bookloreAdapter } = makeRegistry();
|
||||
const { registry, bookloreAdapter, grimmoryAdapter } = makeRegistry();
|
||||
|
||||
expect(registry.get('booklore')).toBe(bookloreAdapter);
|
||||
expect(registry.get(' BOOKLORE ')).toBe(bookloreAdapter);
|
||||
expect(registry.get('grimmory')).toBe(grimmoryAdapter);
|
||||
expect(registry.get(' GRIMMORY ')).toBe(grimmoryAdapter);
|
||||
});
|
||||
|
||||
it('throws BadRequestException for unsupported source types', () => {
|
||||
|
||||
@@ -2,13 +2,14 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type { SourceAdapter } from './source-adapter.types';
|
||||
import { BookloreSourceAdapter } from './booklore/booklore-source.adapter';
|
||||
import { GrimmorySourceAdapter } from './grimmory/grimmory-source.adapter';
|
||||
|
||||
@Injectable()
|
||||
export class SourceAdapterRegistry {
|
||||
private readonly adaptersByType: Map<string, SourceAdapter<any>>;
|
||||
|
||||
constructor(bookloreAdapter: BookloreSourceAdapter) {
|
||||
const adapters: SourceAdapter<any>[] = [bookloreAdapter];
|
||||
constructor(bookloreAdapter: BookloreSourceAdapter, grimmoryAdapter: GrimmorySourceAdapter) {
|
||||
const adapters: SourceAdapter<any>[] = [bookloreAdapter, grimmoryAdapter];
|
||||
this.adaptersByType = new Map(adapters.map((adapter) => [adapter.type, adapter]));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { parseBookloreConnectionConfig } from '../adapters/booklore/booklore-con
|
||||
import { asRecord } from './coerce';
|
||||
|
||||
export function parseConnectionConfig(type: string, raw: unknown): unknown {
|
||||
if (type === 'booklore') {
|
||||
if (type === 'booklore' || type === 'grimmory') {
|
||||
return parseBookloreConnectionConfig(raw);
|
||||
}
|
||||
return asRecord(raw);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { MigrationEncryptionService } from './core/migration-encryption.service'
|
||||
import { SourceAdapterRegistry } from './adapters/source-adapter.registry';
|
||||
import { BookloreSourceAdapter } from './adapters/booklore/booklore-source.adapter';
|
||||
import { BookloreConnector } from './adapters/booklore/booklore-connector';
|
||||
import { GrimmorySourceAdapter } from './adapters/grimmory/grimmory-source.adapter';
|
||||
import { MatchingService } from './planner/matching.service';
|
||||
import { MigrationPlannerService } from './planner/planner.service';
|
||||
import { PathMappingValidationService } from './planner/path-mapping-validation.service';
|
||||
@@ -46,6 +47,7 @@ import { MigrationReportingService } from './reporting/migration-reporting.servi
|
||||
MigrationEncryptionService,
|
||||
BookloreConnector,
|
||||
BookloreSourceAdapter,
|
||||
GrimmorySourceAdapter,
|
||||
SourceAdapterRegistry,
|
||||
MatchingService,
|
||||
MigrationPlannerService,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { StartLiveRunDto } from './dto/start-live-run.dto';
|
||||
import { buildPathMappingsHash, buildPlanHash, buildProfileHash, buildSourceSnapshotHash } from './core/plan-hash';
|
||||
import { asInteger, asRecord, asString } from './core/coerce';
|
||||
import {
|
||||
type ApiMigrationPlanArtifact,
|
||||
type ApiMigrationRun,
|
||||
sanitizeSourceForApi,
|
||||
sanitizeProfileForApi,
|
||||
@@ -97,7 +98,7 @@ export class MigrationService {
|
||||
summary,
|
||||
createdByUserId: userId,
|
||||
});
|
||||
return sanitizePlanArtifactForApi(artifact);
|
||||
return sanitizePlanArtifactWithDuplicateCandidates(artifact);
|
||||
}
|
||||
|
||||
async resolveDuplicateMatches(artifactId: number, dto: ResolveDuplicateMatchesDto) {
|
||||
@@ -186,7 +187,7 @@ export class MigrationService {
|
||||
});
|
||||
|
||||
this.logger.log(`[plan.resolve_duplicates] [end] artifactId=${artifactId} resolved=${resolved.size} - duplicates resolved`);
|
||||
return sanitizePlanArtifactForApi(updated);
|
||||
return sanitizePlanArtifactWithDuplicateCandidates(updated);
|
||||
}
|
||||
|
||||
async startLiveRun(dto: StartLiveRunDto, userId: number) {
|
||||
@@ -342,7 +343,7 @@ export class MigrationService {
|
||||
? {
|
||||
source: sanitizeSourceForApi(this.withDecryptedConfig(activeBundle.source)),
|
||||
profile: activeBundle.profile ? sanitizeProfileForApi(activeBundle.profile) : null,
|
||||
plan: activeBundle.plan ? sanitizePlanArtifactForApi(activeBundle.plan) : null,
|
||||
plan: activeBundle.plan ? sanitizePlanArtifactWithDuplicateCandidates(activeBundle.plan) : null,
|
||||
run: activeBundle.run ? sanitizeRunForApi(activeBundle.run) : null,
|
||||
}
|
||||
: null,
|
||||
@@ -394,6 +395,74 @@ function normalizeDuplicateMatches(duplicate: {
|
||||
}));
|
||||
}
|
||||
|
||||
function sanitizePlanArtifactWithDuplicateCandidates(artifact: MigrationPlanArtifact): ApiMigrationPlanArtifact {
|
||||
const sourceData = artifact.sourceData as SourceExportData | null;
|
||||
const hydratedPlan = hydrateDuplicateSourceCandidates(artifact.plan, sourceData);
|
||||
return sanitizePlanArtifactForApi({ ...artifact, plan: hydratedPlan });
|
||||
}
|
||||
|
||||
function hydrateDuplicateSourceCandidates(planRaw: unknown, sourceData: SourceExportData | null): unknown {
|
||||
const plan = asRecord(planRaw);
|
||||
const duplicateBookMatches = Array.isArray(plan.duplicateBookMatches) ? plan.duplicateBookMatches : [];
|
||||
if (duplicateBookMatches.length === 0) return planRaw;
|
||||
|
||||
const sourceBooksById = new Map(sourceData?.books.map((book) => [book.sourceBookId, book]) ?? []);
|
||||
if (sourceBooksById.size === 0) return planRaw;
|
||||
|
||||
let changed = false;
|
||||
const hydratedDuplicates = duplicateBookMatches.map((item) => {
|
||||
const row = asRecord(item);
|
||||
const sourceBookIds = Array.isArray(row.sourceBookIds) ? row.sourceBookIds.filter((id): id is string => typeof id === 'string') : [];
|
||||
if (sourceBookIds.length === 0) return item;
|
||||
|
||||
const existingCandidates = Array.isArray(row.sourceCandidates) ? row.sourceCandidates : [];
|
||||
if (existingCandidates.length > 0) {
|
||||
const hasMetadata = existingCandidates.some((candidate) => {
|
||||
const value = asRecord(candidate);
|
||||
return typeof value.title === 'string' || typeof value.author === 'string' || typeof value.filePath === 'string';
|
||||
});
|
||||
if (hasMetadata) return item;
|
||||
}
|
||||
|
||||
const duplicateMatches = Array.isArray(row.matches) ? row.matches : [];
|
||||
const strategyBySourceBookId = new Map<string, MatchStrategy>();
|
||||
for (const duplicateMatch of duplicateMatches) {
|
||||
const match = asRecord(duplicateMatch);
|
||||
const sourceBookId = asString(match.sourceBookId);
|
||||
const strategy = asString(match.strategy);
|
||||
if (sourceBookId && (strategy === 'isbn' || strategy === 'file_hash' || strategy === 'path_mapping' || strategy === 'title_author')) {
|
||||
strategyBySourceBookId.set(sourceBookId, strategy);
|
||||
}
|
||||
}
|
||||
|
||||
const strategies = Array.isArray(row.strategies) ? row.strategies.map((value) => asString(value)) : [];
|
||||
const sourceCandidates = sourceBookIds.map((sourceBookId, index) => {
|
||||
const sourceBook = sourceBooksById.get(sourceBookId);
|
||||
const strategyFromMatch = strategyBySourceBookId.get(sourceBookId);
|
||||
const strategyFromList = strategies[index] ?? strategies[0] ?? null;
|
||||
const strategy = strategyFromMatch ?? toMatchStrategy(strategyFromList) ?? 'title_author';
|
||||
return {
|
||||
sourceBookId,
|
||||
title: sourceBook?.title ?? null,
|
||||
author: sourceBook?.author ?? null,
|
||||
filePath: sourceBook?.filePath ?? null,
|
||||
strategy,
|
||||
};
|
||||
});
|
||||
|
||||
changed = true;
|
||||
return { ...row, sourceCandidates };
|
||||
});
|
||||
|
||||
if (!changed) return planRaw;
|
||||
return { ...plan, duplicateBookMatches: hydratedDuplicates };
|
||||
}
|
||||
|
||||
function toMatchStrategy(value: string | null): MatchStrategy | null {
|
||||
if (value === 'isbn' || value === 'file_hash' || value === 'path_mapping' || value === 'title_author') return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePlanUserMappings(raw: unknown): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
if (!Array.isArray(raw)) return out;
|
||||
|
||||
@@ -186,4 +186,200 @@ describe('MatchingService private lookups', () => {
|
||||
|
||||
await expect((service as never).lookupByTitleAuthor('Dune', ['Frank Herbert'])).resolves.toEqual({ kind: 'found', bookId: 777 });
|
||||
});
|
||||
|
||||
it('batchLookupFileHashes returns found, ambiguous, and none results', async () => {
|
||||
const where = vi.fn().mockResolvedValue([
|
||||
{ bookId: 10, hash: 'hash-unique' },
|
||||
{ bookId: 20, hash: 'hash-dup' },
|
||||
{ bookId: 21, hash: 'hash-dup' },
|
||||
]);
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const service = new MatchingService({ select } as never);
|
||||
|
||||
const lookup = await (service as never).batchLookupFileHashes([
|
||||
sourceBook({ sourceBookId: 'a', fileHash: 'hash-unique' }),
|
||||
sourceBook({ sourceBookId: 'b', fileHash: 'hash-dup' }),
|
||||
sourceBook({ sourceBookId: 'c', fileHash: 'hash-missing' }),
|
||||
]);
|
||||
|
||||
expect(lookup.get('hash-unique')).toEqual({ kind: 'found', bookId: 10 });
|
||||
expect(lookup.get('hash-dup')).toEqual({ kind: 'ambiguous' });
|
||||
expect(lookup.get('hash-missing')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('batchLookupFileHashes returns empty map for books without hashes', async () => {
|
||||
const service = new MatchingService({} as never);
|
||||
|
||||
const lookup = await (service as never).batchLookupFileHashes([sourceBook({ sourceBookId: 'a' })]);
|
||||
|
||||
expect(lookup.size).toBe(0);
|
||||
});
|
||||
|
||||
it('lookupByFilePath returns found when exactly one book matches', async () => {
|
||||
const limit = vi.fn().mockResolvedValue([{ bookId: 55 }]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const service = new MatchingService({ select } as never);
|
||||
|
||||
const result = await (service as never).lookupByFilePath('/books/dune.epub');
|
||||
expect(result).toEqual({ kind: 'found', bookId: 55 });
|
||||
});
|
||||
|
||||
it('lookupByFilePath returns ambiguous when multiple books match', async () => {
|
||||
const limit = vi.fn().mockResolvedValue([{ bookId: 55 }, { bookId: 56 }]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const service = new MatchingService({ select } as never);
|
||||
|
||||
const result = await (service as never).lookupByFilePath('/books/dune.epub');
|
||||
expect(result).toEqual({ kind: 'ambiguous' });
|
||||
});
|
||||
|
||||
it('lookupByFilePath returns none when no books match', async () => {
|
||||
const limit = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const from = vi.fn().mockReturnValue({ where });
|
||||
const select = vi.fn().mockReturnValue({ from });
|
||||
const service = new MatchingService({ select } as never);
|
||||
|
||||
const result = await (service as never).lookupByFilePath('/books/missing.epub');
|
||||
expect(result).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('lookupByTitleAuthor returns none when title is empty', async () => {
|
||||
const service = new MatchingService({} as never);
|
||||
const result = await (service as never).lookupByTitleAuthor('', ['Author']);
|
||||
expect(result).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('lookupByTitleAuthor returns none when no valid authors provided', async () => {
|
||||
const service = new MatchingService({} as never);
|
||||
const result = await (service as never).lookupByTitleAuthor('Dune', ['', ' ']);
|
||||
expect(result).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('lookupByTitleAuthor returns exact ambiguous result without calling approx', async () => {
|
||||
const exactLimit = vi.fn().mockResolvedValue([{ bookId: 1 }, { bookId: 2 }]);
|
||||
const exactWhere = vi.fn().mockReturnValue({ limit: exactLimit });
|
||||
const exactInnerJoin2 = vi.fn().mockReturnValue({ where: exactWhere });
|
||||
const exactInnerJoin1 = vi.fn().mockReturnValue({ innerJoin: exactInnerJoin2 });
|
||||
const exactFrom = vi.fn().mockReturnValue({ innerJoin: exactInnerJoin1 });
|
||||
const selectDistinct = vi.fn().mockReturnValueOnce({ from: exactFrom });
|
||||
const service = new MatchingService({ selectDistinct } as never);
|
||||
|
||||
const result = await (service as never).lookupByTitleAuthor('Dune', ['Frank Herbert']);
|
||||
expect(result).toEqual({ kind: 'ambiguous' });
|
||||
expect(selectDistinct).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lookupByTitleAuthor returns none when both exact and approx return empty', async () => {
|
||||
const makeChain = () => {
|
||||
const limit = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn().mockReturnValue({ limit });
|
||||
const innerJoin2 = vi.fn().mockReturnValue({ where });
|
||||
const innerJoin1 = vi.fn().mockReturnValue({ innerJoin: innerJoin2 });
|
||||
const from = vi.fn().mockReturnValue({ innerJoin: innerJoin1 });
|
||||
return { from };
|
||||
};
|
||||
const chain1 = makeChain();
|
||||
const chain2 = makeChain();
|
||||
const selectDistinct = vi.fn().mockReturnValueOnce({ from: chain1.from }).mockReturnValueOnce({ from: chain2.from });
|
||||
const service = new MatchingService({ selectDistinct } as never);
|
||||
|
||||
const result = await (service as never).lookupByTitleAuthor('Unknown Title', ['Unknown Author']);
|
||||
expect(result).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('matchBooks handles books with multiple file paths via files array', async () => {
|
||||
const service = new MatchingService({} as never);
|
||||
|
||||
vi.spyOn(service as never, 'batchLookupIsbns').mockResolvedValue(new Map());
|
||||
vi.spyOn(service as never, 'batchLookupFileHashes').mockResolvedValue(new Map());
|
||||
|
||||
const lookupByFilePath = vi.spyOn(service as never, 'lookupByFilePath').mockImplementation((path: string) => {
|
||||
if (path === '/target/file.epub') return Promise.resolve({ kind: 'found', bookId: 900 });
|
||||
return Promise.resolve({ kind: 'none' });
|
||||
});
|
||||
|
||||
const result = await service.matchBooks(
|
||||
[
|
||||
{
|
||||
sourceBookId: 'multi-file',
|
||||
title: null,
|
||||
author: null,
|
||||
subtitle: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
filePath: null,
|
||||
fileHash: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
files: [
|
||||
{ filePath: '/source/file.epub', fileHash: null },
|
||||
{ filePath: '/source/other.epub', fileHash: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
[{ sourcePrefix: '/source', targetPrefix: '/target' }],
|
||||
);
|
||||
|
||||
expect(result.matches).toEqual([{ sourceBookId: 'multi-file', targetBookId: 900, strategy: 'path_mapping' }]);
|
||||
expect(lookupByFilePath).toHaveBeenCalledWith('/target/file.epub');
|
||||
});
|
||||
|
||||
it('matchBooks deduplicates mapped paths using Set (cache reuse)', async () => {
|
||||
const service = new MatchingService({} as never);
|
||||
vi.spyOn(service as never, 'batchLookupIsbns').mockResolvedValue(new Map());
|
||||
vi.spyOn(service as never, 'batchLookupFileHashes').mockResolvedValue(new Map());
|
||||
const lookupByFilePath = vi.spyOn(service as never, 'lookupByFilePath').mockResolvedValue({ kind: 'found', bookId: 10 });
|
||||
|
||||
await service.matchBooks(
|
||||
[
|
||||
sourceBook({ sourceBookId: 'dup-path', filePath: '/source/same.epub' }),
|
||||
sourceBook({ sourceBookId: 'dup-path-2', filePath: '/source/same.epub' }),
|
||||
],
|
||||
[{ sourcePrefix: '/source', targetPrefix: '/target' }],
|
||||
);
|
||||
|
||||
expect(lookupByFilePath).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('matchBooks uses authors array when available for title_author strategy', async () => {
|
||||
const service = new MatchingService({} as never);
|
||||
vi.spyOn(service as never, 'batchLookupIsbns').mockResolvedValue(new Map());
|
||||
vi.spyOn(service as never, 'batchLookupFileHashes').mockResolvedValue(new Map());
|
||||
const lookupByTitleAuthor = vi.spyOn(service as never, 'lookupByTitleAuthor').mockResolvedValue({ kind: 'found', bookId: 42 });
|
||||
|
||||
await service.matchBooks(
|
||||
[
|
||||
{
|
||||
sourceBookId: 'auth-arr',
|
||||
title: 'Structured Title',
|
||||
author: 'Legacy Author',
|
||||
subtitle: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
description: null,
|
||||
publisher: null,
|
||||
publishedYear: null,
|
||||
language: null,
|
||||
filePath: null,
|
||||
fileHash: null,
|
||||
genres: [],
|
||||
tags: [],
|
||||
authors: [{ name: 'Structured Author 1' }, { name: 'Structured Author 2' }],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(lookupByTitleAuthor).toHaveBeenCalledWith('Structured Title', ['Structured Author 1', 'Structured Author 2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
PathMapping,
|
||||
PlannedBookMatch,
|
||||
PlannedDuplicateBookMatch,
|
||||
PlannedDuplicateSourceCandidate,
|
||||
PlannerResult,
|
||||
PlannedMigration,
|
||||
PlannedUserPreview,
|
||||
@@ -39,7 +40,8 @@ export class MigrationPlannerService {
|
||||
const scope = { ...asRecord(profile.scope), ...(scopeOverride ?? {}) };
|
||||
|
||||
const { matches, unresolved } = await this.matchingService.matchBooks(sourceData.books, pathMappings);
|
||||
const { executableMatches, duplicateBookMatches } = splitDuplicateMatches(matches);
|
||||
const sourceBooksById = new Map(sourceData.books.map((book) => [book.sourceBookId, book]));
|
||||
const { executableMatches, duplicateBookMatches } = splitDuplicateMatches(matches, sourceBooksById);
|
||||
|
||||
const matchedBookIds = new Set(executableMatches.map((entry) => entry.sourceBookId));
|
||||
const mappedUsers = new Map(userMappings.map((entry) => [entry.sourceUserId, entry.targetUserId]));
|
||||
@@ -71,7 +73,10 @@ export class MigrationPlannerService {
|
||||
}
|
||||
}
|
||||
|
||||
function splitDuplicateMatches(matches: PlannedBookMatch[]): {
|
||||
function splitDuplicateMatches(
|
||||
matches: PlannedBookMatch[],
|
||||
sourceBooksById: Map<string, { title: string | null; author: string | null; filePath: string | null }>,
|
||||
): {
|
||||
executableMatches: PlannedBookMatch[];
|
||||
duplicateBookMatches: PlannedDuplicateBookMatch[];
|
||||
} {
|
||||
@@ -87,11 +92,22 @@ function splitDuplicateMatches(matches: PlannedBookMatch[]): {
|
||||
for (const [targetBookId, rows] of byTarget) {
|
||||
if (rows.length < 2) continue;
|
||||
duplicateTargetIds.add(targetBookId);
|
||||
const sourceCandidates: PlannedDuplicateSourceCandidate[] = rows.map((row) => {
|
||||
const sourceBook = sourceBooksById.get(row.sourceBookId);
|
||||
return {
|
||||
sourceBookId: row.sourceBookId,
|
||||
title: sourceBook?.title ?? null,
|
||||
author: sourceBook?.author ?? null,
|
||||
filePath: sourceBook?.filePath ?? null,
|
||||
strategy: row.strategy,
|
||||
};
|
||||
});
|
||||
duplicateBookMatches.push({
|
||||
targetBookId,
|
||||
matches: rows,
|
||||
sourceBookIds: rows.map((row) => row.sourceBookId),
|
||||
strategies: rows.map((row) => row.strategy),
|
||||
sourceCandidates,
|
||||
reason: 'duplicate_target_match',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,11 +30,20 @@ export interface PlannedBookMatch {
|
||||
strategy: MatchStrategy;
|
||||
}
|
||||
|
||||
export interface PlannedDuplicateSourceCandidate {
|
||||
sourceBookId: string;
|
||||
title: string | null;
|
||||
author: string | null;
|
||||
filePath: string | null;
|
||||
strategy: MatchStrategy;
|
||||
}
|
||||
|
||||
export interface PlannedDuplicateBookMatch {
|
||||
targetBookId: number;
|
||||
matches: PlannedBookMatch[];
|
||||
sourceBookIds: string[];
|
||||
strategies: MatchStrategy[];
|
||||
sourceCandidates?: PlannedDuplicateSourceCandidate[];
|
||||
reason: 'duplicate_target_match';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MigrationReportingService } from './migration-reporting.service';
|
||||
|
||||
@@ -210,4 +211,322 @@ describe('MigrationReportingService', () => {
|
||||
expect(result.content).toContain('section,stage,entityType');
|
||||
expect(result.content).toContain('"Title, ""Quoted"""');
|
||||
});
|
||||
|
||||
it('exports CSV with metrics, unresolved books, duplicate matches, and user preview rows', async () => {
|
||||
const { service } = makeService();
|
||||
vi.spyOn(service, 'getRunReport').mockResolvedValue({
|
||||
run: makeRun(),
|
||||
totals: { processed: 10, imported: 8, skipped: 1, unresolved: 1, failed: 0 },
|
||||
metrics: [
|
||||
{
|
||||
stage: 'user_state',
|
||||
entityType: 'reading_progress',
|
||||
processed: 10,
|
||||
imported: 8,
|
||||
skipped: 1,
|
||||
unresolved: 1,
|
||||
failed: 0,
|
||||
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
||||
} as never,
|
||||
],
|
||||
plan: {},
|
||||
summary: {},
|
||||
details: {
|
||||
matchedBooks: [{ sourceBookId: 's1', sourceTitle: 'T', sourceAuthor: 'A', targetBookId: 1, targetTitle: 'TT', strategy: 'isbn' }],
|
||||
unresolvedBooks: [{ sourceBookId: 'sx', title: 'Unresolved Title', author: 'UA', reason: 'no_isbn_match' }],
|
||||
duplicateBookMatches: [
|
||||
{
|
||||
targetBookId: 2,
|
||||
targetTitle: 'Dup Target',
|
||||
sourceBookIds: ['s2', 's3'],
|
||||
sourceTitles: ['Source 2', null],
|
||||
strategies: ['isbn', 'title_author'],
|
||||
reason: 'duplicate_target_match',
|
||||
},
|
||||
],
|
||||
userPreview: [
|
||||
{
|
||||
sourceUserId: 'u1',
|
||||
targetUserId: 9,
|
||||
username: 'alice',
|
||||
counts: { statuses: 1, fileProgress: 2, bookmarks: 3, annotations: 4, shelves: 5 },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await service.exportRunReport(7, 'csv');
|
||||
|
||||
expect(result.format).toBe('csv');
|
||||
expect(result.content).toContain('user_state');
|
||||
expect(result.content).toContain('reading_progress');
|
||||
expect(result.content).toContain('matched_books');
|
||||
expect(result.content).toContain('s1');
|
||||
expect(result.content).toContain('unresolved_books');
|
||||
expect(result.content).toContain('sx');
|
||||
expect(result.content).toContain('duplicate_matches');
|
||||
expect(result.content).toContain('s2|s3');
|
||||
expect(result.content).toContain('user_preview');
|
||||
expect(result.content).toContain('u1');
|
||||
});
|
||||
|
||||
it('exports empty CSV when no details exist', async () => {
|
||||
const { service } = makeService();
|
||||
vi.spyOn(service, 'getRunReport').mockResolvedValue({
|
||||
run: makeRun(),
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
plan: null,
|
||||
summary: null,
|
||||
details: { matchedBooks: [], unresolvedBooks: [], duplicateBookMatches: [], userPreview: [] },
|
||||
} as never);
|
||||
|
||||
const result = await service.exportRunReport(7, 'csv');
|
||||
|
||||
expect(result.format).toBe('csv');
|
||||
expect(result.content).toContain('summary');
|
||||
expect(result.content).not.toContain('matched_books');
|
||||
});
|
||||
|
||||
it('handles run with no planArtifactId', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun({ planArtifactId: null }));
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
|
||||
const result = await service.getRunReport(7);
|
||||
|
||||
expect(result.details.matchedBooks).toHaveLength(0);
|
||||
expect(result.details.unresolvedBooks).toHaveLength(0);
|
||||
expect(result.details.userPreview).toHaveLength(0);
|
||||
expect(result.plan).toBeNull();
|
||||
expect(result.summary).toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes unresolved books enriching title from sourceData', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun());
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
repo.findPlanArtifactById.mockResolvedValue({
|
||||
sourceData: {
|
||||
books: [{ sourceBookId: 'sx', title: 'Enriched Title', author: 'EA', filePath: null }],
|
||||
},
|
||||
plan: {
|
||||
matchedBooks: [],
|
||||
unresolvedBooks: [{ sourceBookId: 'sx', title: null, reason: 'no_isbn_match' }],
|
||||
duplicateBookMatches: [],
|
||||
},
|
||||
summary: { perUserPreview: [] },
|
||||
});
|
||||
|
||||
const result = await service.getRunReport(7);
|
||||
|
||||
expect(result.details.unresolvedBooks[0]).toMatchObject({
|
||||
sourceBookId: 'sx',
|
||||
title: 'Enriched Title',
|
||||
author: 'EA',
|
||||
reason: 'no_isbn_match',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes userPreview falling back to summary.perUserPreview when plan has no userPreview', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun());
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
repo.findPlanArtifactById.mockResolvedValue({
|
||||
sourceData: null,
|
||||
plan: {
|
||||
matchedBooks: [],
|
||||
unresolvedBooks: [],
|
||||
duplicateBookMatches: [],
|
||||
},
|
||||
summary: {
|
||||
perUserPreview: [
|
||||
{
|
||||
sourceUserId: 'u2',
|
||||
targetUserId: 5,
|
||||
username: 'bob',
|
||||
counts: { statuses: 0, fileProgress: 0, bookmarks: 0, annotations: 0, shelves: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getRunReport(7);
|
||||
|
||||
expect(result.details.userPreview).toHaveLength(1);
|
||||
expect(result.details.userPreview[0]).toMatchObject({ sourceUserId: 'u2', username: 'bob' });
|
||||
});
|
||||
|
||||
it('returns aggregated zero totals for empty metrics', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun());
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
repo.findPlanArtifactById.mockResolvedValue({
|
||||
sourceData: null,
|
||||
plan: null,
|
||||
summary: null,
|
||||
});
|
||||
|
||||
const result = await service.getRunProgress(7);
|
||||
|
||||
expect(result.totals).toEqual({ processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 });
|
||||
});
|
||||
|
||||
it('getRunProgress throws NotFoundException when run is missing', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getRunProgress(404)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('normalizeMatchedBooks skips rows with missing fields', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun());
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
repo.findPlanArtifactById.mockResolvedValue({
|
||||
sourceData: null,
|
||||
plan: {
|
||||
matchedBooks: [
|
||||
{ sourceBookId: 's1', targetBookId: 10, strategy: 'isbn' },
|
||||
{ sourceBookId: null, targetBookId: 10, strategy: 'isbn' },
|
||||
{ sourceBookId: 's2', targetBookId: null, strategy: 'isbn' },
|
||||
{ sourceBookId: 's3', targetBookId: 10, strategy: null },
|
||||
],
|
||||
unresolvedBooks: [],
|
||||
duplicateBookMatches: [],
|
||||
},
|
||||
summary: { perUserPreview: [] },
|
||||
});
|
||||
|
||||
const result = await service.getRunReport(7);
|
||||
expect(result.details.matchedBooks).toHaveLength(1);
|
||||
expect(result.details.matchedBooks[0].sourceBookId).toBe('s1');
|
||||
});
|
||||
|
||||
it('normalizeUnresolvedBooks skips rows with missing sourceBookId or reason', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun());
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
repo.findPlanArtifactById.mockResolvedValue({
|
||||
sourceData: null,
|
||||
plan: {
|
||||
matchedBooks: [],
|
||||
unresolvedBooks: [
|
||||
{ sourceBookId: 'sx', reason: 'no_isbn_match' },
|
||||
{ sourceBookId: null, reason: 'no_isbn_match' },
|
||||
{ sourceBookId: 'sy', reason: null },
|
||||
],
|
||||
duplicateBookMatches: [],
|
||||
},
|
||||
summary: { perUserPreview: [] },
|
||||
});
|
||||
|
||||
const result = await service.getRunReport(7);
|
||||
expect(result.details.unresolvedBooks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('normalizeDuplicateBookMatches skips rows with missing targetBookId or empty sourceBookIds', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.findRunById.mockResolvedValue(makeRun());
|
||||
repo.listRunMetrics.mockResolvedValue([]);
|
||||
repo.findBookTitlesByIds.mockResolvedValue(new Map());
|
||||
repo.findPlanArtifactById.mockResolvedValue({
|
||||
sourceData: null,
|
||||
plan: {
|
||||
matchedBooks: [],
|
||||
unresolvedBooks: [],
|
||||
duplicateBookMatches: [
|
||||
{ targetBookId: 5, sourceBookIds: ['s1', 's2'], strategies: ['isbn', 'title_author'] },
|
||||
{ targetBookId: null, sourceBookIds: ['s3'], strategies: ['isbn'] },
|
||||
{ targetBookId: 6, sourceBookIds: [], strategies: [] },
|
||||
],
|
||||
},
|
||||
summary: { perUserPreview: [] },
|
||||
});
|
||||
|
||||
const result = await service.getRunReport(7);
|
||||
expect(result.details.duplicateBookMatches).toHaveLength(1);
|
||||
expect(result.details.duplicateBookMatches[0].targetBookId).toBe(5);
|
||||
});
|
||||
|
||||
it('exports CSV with newline in content correctly escaped', async () => {
|
||||
const { service } = makeService();
|
||||
vi.spyOn(service, 'getRunReport').mockResolvedValue({
|
||||
run: makeRun(),
|
||||
totals: { processed: 1, imported: 1, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
plan: {},
|
||||
summary: {},
|
||||
details: {
|
||||
matchedBooks: [],
|
||||
unresolvedBooks: [],
|
||||
duplicateBookMatches: [],
|
||||
userPreview: [
|
||||
{
|
||||
sourceUserId: 'u1',
|
||||
targetUserId: 1,
|
||||
username: 'user\nwith\nnewlines',
|
||||
counts: { statuses: 0, fileProgress: 0, bookmarks: 0, annotations: 0, shelves: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await service.exportRunReport(7, 'csv');
|
||||
expect(result.content).toContain('"user\nwith\nnewlines"');
|
||||
});
|
||||
|
||||
it('uses format=csv case-insensitively', async () => {
|
||||
const { service } = makeService();
|
||||
vi.spyOn(service, 'getRunReport').mockResolvedValue({
|
||||
run: makeRun(),
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
plan: null,
|
||||
summary: null,
|
||||
details: { matchedBooks: [], unresolvedBooks: [], duplicateBookMatches: [], userPreview: [] },
|
||||
} as never);
|
||||
|
||||
const result = await service.exportRunReport(7, 'CSV');
|
||||
expect(result.format).toBe('csv');
|
||||
expect(result.contentType).toBe('text/csv; charset=utf-8');
|
||||
});
|
||||
|
||||
it('defaults to JSON format when format param is unrecognized', async () => {
|
||||
const { service } = makeService();
|
||||
vi.spyOn(service, 'getRunReport').mockResolvedValue({
|
||||
run: makeRun(),
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
plan: null,
|
||||
summary: null,
|
||||
details: { matchedBooks: [], unresolvedBooks: [], duplicateBookMatches: [], userPreview: [] },
|
||||
} as never);
|
||||
|
||||
const result = await service.exportRunReport(7, 'xml');
|
||||
expect(result.format).toBe('json');
|
||||
expect(result.contentType).toBe('application/json');
|
||||
});
|
||||
|
||||
it('includes run.updatedAt fallback to createdAt in CSV summary row', async () => {
|
||||
const { service } = makeService();
|
||||
const runWithoutUpdatedAt = { ...makeRun(), updatedAt: null };
|
||||
vi.spyOn(service, 'getRunReport').mockResolvedValue({
|
||||
run: runWithoutUpdatedAt as never,
|
||||
totals: { processed: 0, imported: 0, skipped: 0, unresolved: 0, failed: 0 },
|
||||
metrics: [],
|
||||
plan: null,
|
||||
summary: null,
|
||||
details: { matchedBooks: [], unresolvedBooks: [], duplicateBookMatches: [], userPreview: [] },
|
||||
} as never);
|
||||
|
||||
const result = await service.exportRunReport(7, 'csv');
|
||||
expect(result.content).toContain('2026-01-01T00:00:00.000Z');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user