mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(books): add file upload UI to book detail files tab
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { CheckCircle2, FileUp, Loader2, Plus, Upload, X, XCircle, RotateCcw } from 'lucide-vue-next'
|
||||
import { Permission } from '@bookorbit/types'
|
||||
import { SUPPORTED_FORMATS_ACCEPT, useAddBookFile } from '@/features/book/composables/useAddBookFile'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
|
||||
const props = defineProps<{
|
||||
bookId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
uploaded: []
|
||||
}>()
|
||||
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const isDragging = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const renameAfter = ref(false)
|
||||
|
||||
const { files, pendingCount, isUploading, doneCount, errorCount, addFiles, removeFile, retryFile, reset, startUpload } = useAddBookFile()
|
||||
|
||||
const canUpload = computed(() => pendingCount.value > 0 && !isUploading.value)
|
||||
const hasFiles = computed(() => files.value.length > 0)
|
||||
const allDone = computed(() => hasFiles.value && files.value.every((f) => f.status === 'done' || f.status === 'error'))
|
||||
const allSuccess = computed(() => allDone.value && errorCount.value === 0)
|
||||
|
||||
const headerTitle = computed(() => {
|
||||
if (isUploading.value) return 'Uploading...'
|
||||
if (allDone.value) return 'Upload complete'
|
||||
return 'Add File'
|
||||
})
|
||||
|
||||
const fileSummary = computed(() => {
|
||||
if (!hasFiles.value) return null
|
||||
const totalBytes = files.value.reduce((sum, f) => sum + f.file.size, 0)
|
||||
const byFormat: Record<string, number> = {}
|
||||
for (const f of files.value) {
|
||||
const ext = f.file.name.split('.').pop()?.toUpperCase() ?? '?'
|
||||
byFormat[ext] = (byFormat[ext] ?? 0) + 1
|
||||
}
|
||||
const formatParts = Object.entries(byFormat).map(([ext, count]) => `${count} ${ext}`)
|
||||
return { total: files.value.length, totalBytes, formatParts }
|
||||
})
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') handleClose()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||
|
||||
function handleClose() {
|
||||
if (doneCount.value > 0) emit('uploaded')
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
isDragging.value = false
|
||||
const dropped = Array.from(e.dataTransfer?.files ?? [])
|
||||
if (dropped.length > 0) addFiles(dropped)
|
||||
}
|
||||
|
||||
function openFilePicker() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
function onFileInputChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const picked = Array.from(input.files ?? [])
|
||||
if (picked.length > 0) addFiles(picked)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (pendingCount.value === 0 || isUploading.value) return
|
||||
await startUpload(props.bookId, { renameAfter: renameAfter.value })
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function fileRowClass(status: string): string {
|
||||
if (status === 'done') return 'bg-emerald-500/5 border-emerald-500/20'
|
||||
if (status === 'error') return 'bg-destructive/5 border-destructive/20'
|
||||
if (status === 'uploading') return 'bg-primary/5 border-primary/20'
|
||||
return 'bg-muted/40 border-border/40'
|
||||
}
|
||||
|
||||
function getFormat(filename: string): string {
|
||||
return filename.split('.').pop()?.toUpperCase() ?? '?'
|
||||
}
|
||||
|
||||
function formatPillClass(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const map: Record<string, string> = {
|
||||
epub: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
||||
kepub: 'bg-violet-500/15 text-violet-600 dark:text-violet-400',
|
||||
pdf: 'bg-red-500/15 text-red-600 dark:text-red-400',
|
||||
mobi: 'bg-amber-500/15 text-amber-600 dark:text-amber-400',
|
||||
azw3: 'bg-orange-500/15 text-orange-600 dark:text-orange-400',
|
||||
cbz: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
||||
cbr: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
||||
cb7: 'bg-sky-500/15 text-sky-600 dark:text-sky-400',
|
||||
fb2: 'bg-teal-500/15 text-teal-600 dark:text-teal-400',
|
||||
}
|
||||
return map[ext] ?? 'bg-muted text-muted-foreground/85'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed inset-0 z-[70] flex items-center justify-center p-2 sm:p-4">
|
||||
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="handleClose" />
|
||||
|
||||
<div
|
||||
class="relative flex flex-col w-full max-w-2xl bg-background rounded-lg shadow-2xl border border-border overflow-hidden"
|
||||
style="max-height: min(92vh, 720px)"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold text-foreground">{{ headerTitle }}</span>
|
||||
<span
|
||||
v-if="hasFiles"
|
||||
class="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/15 text-primary text-xs font-semibold tabular-nums"
|
||||
>
|
||||
{{ files.length }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center w-7 h-7 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
<X :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-5 flex flex-col gap-4">
|
||||
<!-- Shared file input -->
|
||||
<input ref="fileInputRef" type="file" multiple :accept="SUPPORTED_FORMATS_ACCEPT" class="sr-only" @change="onFileInputChange" />
|
||||
|
||||
<!-- Full dropzone (no files queued yet) -->
|
||||
<div
|
||||
v-if="!hasFiles"
|
||||
class="flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer"
|
||||
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-border/80 hover:bg-muted/30'"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<div class="flex items-center justify-center w-11 h-11 rounded-lg bg-primary/10">
|
||||
<FileUp :size="20" class="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">Drop files here or click to browse</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">epub, pdf, mobi, cbz, m4b and more - up to 500 MB each</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Compact add-more strip (files queued) -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-center justify-center gap-2 rounded-lg border-2 border-dashed py-2.5 px-4 transition-colors cursor-pointer text-xs font-medium"
|
||||
:class="
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-muted/30'
|
||||
"
|
||||
@dragover="onDragOver"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDrop"
|
||||
@click="openFilePicker"
|
||||
>
|
||||
<Plus :size="13" />
|
||||
Add more files
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<div v-if="hasFiles" class="flex flex-col gap-2">
|
||||
<!-- Summary line -->
|
||||
<div v-if="fileSummary" class="flex items-center gap-1.5 flex-wrap text-xs text-muted-foreground">
|
||||
<span class="font-medium text-foreground tabular-nums">{{ fileSummary.total }} file{{ fileSummary.total === 1 ? '' : 's' }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatBytes(fileSummary.totalBytes) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ fileSummary.formatParts.join(', ') }}</span>
|
||||
<div class="flex-1" />
|
||||
<button v-if="!isUploading" class="hover:text-foreground transition-colors" @click="reset">Clear all</button>
|
||||
</div>
|
||||
|
||||
<TransitionGroup name="file-row" tag="div" class="relative flex flex-col gap-1">
|
||||
<div
|
||||
v-for="item in files"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors"
|
||||
:class="fileRowClass(item.status)"
|
||||
>
|
||||
<!-- Status icon -->
|
||||
<div class="shrink-0">
|
||||
<Loader2 v-if="item.status === 'uploading'" :size="16" class="text-primary animate-spin" />
|
||||
<CheckCircle2 v-else-if="item.status === 'done'" :size="16" class="text-emerald-500" />
|
||||
<XCircle v-else-if="item.status === 'error'" :size="16" class="text-destructive" />
|
||||
<div v-else class="w-4 h-4 rounded-full border-2 border-border" />
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-foreground truncate">{{ item.file.name }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||||
<span class="text-[11px] text-muted-foreground">{{ formatBytes(item.file.size) }}</span>
|
||||
<span class="text-[10px] font-semibold px-1.5 py-px rounded uppercase tracking-wide" :class="formatPillClass(item.file.name)">
|
||||
{{ getFormat(item.file.name) }}
|
||||
</span>
|
||||
<span v-if="item.status === 'error'" class="text-[11px] text-destructive truncate">{{ item.error }}</span>
|
||||
<span v-else-if="item.status === 'uploading'" class="text-[11px] text-primary tabular-nums">{{ item.progress }}%</span>
|
||||
<span v-else-if="item.status === 'done'" class="text-[11px] text-emerald-600 dark:text-emerald-400">Done</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div v-if="item.status === 'uploading'" class="mt-1.5 h-1 rounded-full bg-primary/20 overflow-hidden">
|
||||
<div class="h-full bg-primary rounded-full transition-all duration-150" :style="{ width: `${item.progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="shrink-0 flex items-center gap-0.5">
|
||||
<button
|
||||
v-if="item.status === 'error'"
|
||||
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/85 hover:text-primary hover:bg-primary/10 transition-colors"
|
||||
title="Retry"
|
||||
@click="retryFile(item.id)"
|
||||
>
|
||||
<RotateCcw :size="11" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status !== 'uploading'"
|
||||
class="flex items-center justify-center w-6 h-6 rounded text-muted-foreground/70 hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="removeFile(item.id)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer: all succeeded -->
|
||||
<div v-if="allSuccess" class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<CheckCircle2 class="size-4 text-emerald-500 shrink-0" />
|
||||
<span class="text-sm font-medium text-foreground">{{ doneCount }} file{{ doneCount === 1 ? '' : 's' }} added</span>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer: normal / partial error state -->
|
||||
<div v-else class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span v-if="allDone && errorCount > 0" class="text-xs text-muted-foreground">{{ doneCount }} uploaded, {{ errorCount }} failed</span>
|
||||
<span v-else-if="isUploading" class="text-xs text-muted-foreground tabular-nums">{{ doneCount }} of {{ files.length }} uploading...</span>
|
||||
<label v-else-if="hasPermission(Permission.LibraryEditMetadata)" class="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
v-model="renameAfter"
|
||||
type="checkbox"
|
||||
class="w-3.5 h-3.5 rounded border-border accent-primary cursor-pointer"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">Rename all book files after upload</span>
|
||||
</label>
|
||||
<span v-else class="text-xs text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
{{ allDone ? 'Close' : 'Cancel' }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
:disabled="!canUpload"
|
||||
@click="handleUpload"
|
||||
>
|
||||
<Upload :size="13" />
|
||||
Upload{{ pendingCount > 0 ? ` (${pendingCount})` : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-row-enter-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.file-row-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
.file-row-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
.file-row-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(6px);
|
||||
}
|
||||
.file-row-move {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -2,17 +2,19 @@
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { BookOpen, Download, Eye, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
|
||||
import { BookOpen, Download, Eye, FilePlus, Files, Headphones, History, FolderOpen, ArrowUpDown, MoreVertical } from 'lucide-vue-next'
|
||||
import type { BookDetail, BookDetailFile, WriteLogEntry } from '@bookorbit/types'
|
||||
import { READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { Permission, READER_OPENABLE_FORMATS } from '@bookorbit/types'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { api } from '@/lib/api'
|
||||
import { useBookDownload } from '@/features/book/composables/useBookDownload'
|
||||
import { getFormatColor } from '@/features/book/lib/format-colors'
|
||||
import { usePermissions } from '@/features/auth/composables/usePermissions'
|
||||
import AddBookFileModal from './AddBookFileModal.vue'
|
||||
|
||||
const props = defineProps<{ book: BookDetail }>()
|
||||
const emit = defineEmits<{ refetch: [] }>()
|
||||
const router = useRouter()
|
||||
|
||||
const { downloadFile: downloadBookFile } = useBookDownload()
|
||||
@@ -136,6 +138,17 @@ const renaming = ref(false)
|
||||
const deleteFileTarget = ref<BookDetailFile | null>(null)
|
||||
const deletingFile = ref(false)
|
||||
|
||||
const addFileModalOpen = ref(false)
|
||||
|
||||
function openAddFileModal() {
|
||||
addFileModalOpen.value = true
|
||||
}
|
||||
|
||||
function onFilesAdded() {
|
||||
addFileModalOpen.value = false
|
||||
emit('refetch')
|
||||
}
|
||||
|
||||
function openRenameModal(file: BookDetailFile) {
|
||||
renameFileTarget.value = file
|
||||
renameInput.value = file.filename ?? ''
|
||||
@@ -151,8 +164,8 @@ async function submitRename() {
|
||||
body: JSON.stringify({ filename: renameInput.value.trim() }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to rename file')
|
||||
// We can emit an event or just reload since we don't have a direct refetch prop.
|
||||
window.location.reload()
|
||||
renameFileTarget.value = null
|
||||
emit('refetch')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -173,7 +186,7 @@ async function confirmDelete() {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete file')
|
||||
window.location.reload()
|
||||
emit('refetch')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -217,10 +230,19 @@ async function toggleWriteLog() {
|
||||
class="sticky top-0 z-20 -mx-4 border-b border-border/70 bg-card/95 px-4 pb-3 pt-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] backdrop-blur supports-[backdrop-filter]:bg-card/85 sm:static sm:mx-0 sm:border-b-0 sm:bg-transparent sm:px-0 sm:py-1 sm:backdrop-blur-none"
|
||||
>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p v-if="book.lastWrittenAt" class="text-sm md:text-xs font-medium text-muted-foreground/90 truncate">
|
||||
Last synced: {{ formatRelative(book.lastWrittenAt) }}
|
||||
</p>
|
||||
<span v-else class="hidden sm:inline" />
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="hasPermission(Permission.LibraryUpload)"
|
||||
class="flex items-center gap-1.5 h-8 md:h-6 px-2.5 md:px-2 rounded-md text-sm md:text-xs font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors whitespace-nowrap"
|
||||
@click="openAddFileModal"
|
||||
>
|
||||
<FilePlus class="size-4 md:size-3" />
|
||||
Add File
|
||||
</button>
|
||||
<p v-if="book.lastWrittenAt" class="text-sm md:text-xs font-medium text-muted-foreground/90 truncate">
|
||||
Last synced: {{ formatRelative(book.lastWrittenAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 overflow-x-auto scrollbar-none pb-1 -mb-1 sm:overflow-visible sm:pb-0 sm:mb-0">
|
||||
<div class="flex items-center gap-1.5 whitespace-nowrap">
|
||||
<ArrowUpDown class="size-4 md:size-3 text-muted-foreground" />
|
||||
@@ -516,4 +538,6 @@ async function toggleWriteLog() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddBookFileModal v-if="addFileModalOpen" :book-id="book.id" @close="addFileModalOpen = false" @uploaded="onFilesAdded" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getAccessToken: vi.fn<() => string>().mockReturnValue('test-token'),
|
||||
}))
|
||||
|
||||
interface MockXhrInstance {
|
||||
open: ReturnType<typeof vi.fn>
|
||||
setRequestHeader: ReturnType<typeof vi.fn>
|
||||
send: ReturnType<typeof vi.fn>
|
||||
upload: { onprogress: ((e: ProgressEvent) => void) | null }
|
||||
onload: (() => void) | null
|
||||
onerror: (() => void) | null
|
||||
status: number
|
||||
responseText: string
|
||||
}
|
||||
|
||||
const mockXhrInstances: MockXhrInstance[] = []
|
||||
|
||||
const defaultResponseText = JSON.stringify({
|
||||
id: 55,
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
sizeBytes: 1024,
|
||||
absolutePath: '/library/Book/file.epub',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
filename: 'file.epub',
|
||||
durationSeconds: null,
|
||||
bookStatus: 'present',
|
||||
})
|
||||
|
||||
class MockXMLHttpRequest implements MockXhrInstance {
|
||||
open = vi.fn<(method: string, url: string) => void>()
|
||||
setRequestHeader = vi.fn<(name: string, value: string) => void>()
|
||||
send = vi.fn<() => void>()
|
||||
upload: { onprogress: ((e: ProgressEvent) => void) | null } = { onprogress: null }
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
status = 201
|
||||
responseText = defaultResponseText
|
||||
|
||||
constructor() {
|
||||
mockXhrInstances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('XMLHttpRequest', MockXMLHttpRequest)
|
||||
|
||||
import { useAddBookFile, SUPPORTED_FORMATS, MAX_FILE_BYTES } from '../useAddBookFile'
|
||||
|
||||
function makeFile(name: string, sizeBytes = 1024, lastModified = 1000): File {
|
||||
const blob = new Blob(['x'])
|
||||
const file = new File([blob], name, { lastModified })
|
||||
Object.defineProperty(file, 'size', { value: sizeBytes, configurable: true })
|
||||
return file
|
||||
}
|
||||
|
||||
describe('useAddBookFile', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockXhrInstances.length = 0
|
||||
mockFetch = vi.fn<() => Promise<{ ok: boolean }>>().mockResolvedValue({ ok: true })
|
||||
vi.stubGlobal('fetch', mockFetch)
|
||||
})
|
||||
|
||||
describe('addFiles', () => {
|
||||
it('adds a valid file to the queue as pending', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
expect(files.value).toHaveLength(1)
|
||||
expect(files.value[0].status).toBe('pending')
|
||||
expect(files.value[0].file.name).toBe('book.epub')
|
||||
})
|
||||
|
||||
it('marks unsupported format as error without attempting upload', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('book.xyz')])
|
||||
expect(files.value[0].status).toBe('error')
|
||||
expect(files.value[0].error).toContain('.xyz')
|
||||
})
|
||||
|
||||
it('sets validationError=true for unsupported format', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('book.xyz')])
|
||||
expect(files.value[0].validationError).toBe(true)
|
||||
})
|
||||
|
||||
it('sets validationError=false for valid files', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
expect(files.value[0].validationError).toBe(false)
|
||||
})
|
||||
|
||||
it('marks file exceeding 500 MB as error', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
const oversized = makeFile('big.epub', MAX_FILE_BYTES + 1)
|
||||
Object.defineProperty(oversized, 'size', { value: MAX_FILE_BYTES + 1 })
|
||||
addFiles([oversized])
|
||||
expect(files.value[0].status).toBe('error')
|
||||
expect(files.value[0].error).toContain('500 MB')
|
||||
})
|
||||
|
||||
it('accepts all supported formats', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
const validFiles = SUPPORTED_FORMATS.map((fmt) => makeFile(`book.${fmt}`))
|
||||
addFiles(validFiles)
|
||||
expect(files.value.every((f) => f.status === 'pending')).toBe(true)
|
||||
})
|
||||
|
||||
it('deduplicates files with same name and size', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
const file = makeFile('book.epub', 1024)
|
||||
addFiles([file])
|
||||
addFiles([file])
|
||||
expect(files.value).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('allows two files with same name but different size', () => {
|
||||
const { files, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub', 1024), makeFile('book.epub', 2048)])
|
||||
expect(files.value).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeFile', () => {
|
||||
it('removes file by id', () => {
|
||||
const { files, addFiles, removeFile } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
const id = files.value[0].id
|
||||
removeFile(id)
|
||||
expect(files.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is a no-op for unknown id', () => {
|
||||
const { files, addFiles, removeFile } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
removeFile('non-existent')
|
||||
expect(files.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryFile', () => {
|
||||
it('resets a server/network error file back to pending', async () => {
|
||||
const { files, addFiles, retryFile, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onerror?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('error')
|
||||
retryFile(files.value[0].id)
|
||||
expect(files.value[0].status).toBe('pending')
|
||||
expect(files.value[0].error).toBeUndefined()
|
||||
expect(files.value[0].progress).toBe(0)
|
||||
})
|
||||
|
||||
it('does not retry files that failed client-side validation', () => {
|
||||
const { files, addFiles, retryFile } = useAddBookFile()
|
||||
addFiles([makeFile('book.xyz')]) // validation error
|
||||
const id = files.value[0].id
|
||||
retryFile(id)
|
||||
expect(files.value[0].status).toBe('error') // unchanged
|
||||
expect(files.value[0].error).toBeDefined() // error message preserved
|
||||
})
|
||||
|
||||
it('is a no-op for files not in error state', () => {
|
||||
const { files, addFiles, retryFile } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
retryFile(files.value[0].id)
|
||||
expect(files.value[0].status).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('reset', () => {
|
||||
it('clears all files from the queue', () => {
|
||||
const { files, addFiles, reset } = useAddBookFile()
|
||||
addFiles([makeFile('a.epub'), makeFile('b.pdf')])
|
||||
reset()
|
||||
expect(files.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed counts', () => {
|
||||
it('pendingCount reflects pending files', () => {
|
||||
const { pendingCount, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('a.epub'), makeFile('b.pdf')])
|
||||
expect(pendingCount.value).toBe(2)
|
||||
})
|
||||
|
||||
it('errorCount reflects error files', () => {
|
||||
const { errorCount, addFiles } = useAddBookFile()
|
||||
addFiles([makeFile('bad.xyz'), makeFile('b.pdf')])
|
||||
expect(errorCount.value).toBe(1)
|
||||
})
|
||||
|
||||
it('isUploading is false when no uploads in flight', () => {
|
||||
const { isUploading } = useAddBookFile()
|
||||
expect(isUploading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startUpload', () => {
|
||||
it('sends XHR POST to /api/v1/books/:bookId/files', async () => {
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(xhr.open).toHaveBeenCalledWith('POST', '/api/v1/books/42/files')
|
||||
})
|
||||
|
||||
it('sets Authorization header with token', async () => {
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(xhr.setRequestHeader).toHaveBeenCalledWith('Authorization', 'Bearer test-token')
|
||||
})
|
||||
|
||||
it('marks file as done on 201 and stores result', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 201
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('done')
|
||||
expect(files.value[0].result?.bookStatus).toBe('present')
|
||||
})
|
||||
|
||||
it('marks file as error on non-201 response', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 409
|
||||
xhr.responseText = JSON.stringify({ message: 'File already attached' })
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('error')
|
||||
expect(files.value[0].error).toBe('File already attached')
|
||||
})
|
||||
|
||||
it('marks file as error on network failure', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onerror?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('error')
|
||||
expect(files.value[0].error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('skips already-done or error files on startUpload', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('bad.xyz'), makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('error') // was already error, not re-uploaded
|
||||
expect(mockXhrInstances).toHaveLength(1) // only 1 XHR created
|
||||
})
|
||||
|
||||
it('updates progress during upload', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.upload.onprogress?.({ lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent)
|
||||
|
||||
expect(files.value[0].progress).toBe(50)
|
||||
expect(files.value[0].status).toBe('uploading')
|
||||
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
})
|
||||
|
||||
it('is a no-op when there are no pending files', async () => {
|
||||
const { startUpload } = useAddBookFile()
|
||||
await startUpload(7)
|
||||
expect(mockXhrInstances).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back gracefully when response body is not valid JSON', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 201
|
||||
xhr.responseText = 'not-json'
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('done')
|
||||
expect(files.value[0].result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('shows fallback error message when error response is not JSON', async () => {
|
||||
const { files, addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(7)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 500
|
||||
xhr.responseText = 'Internal Server Error'
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(files.value[0].status).toBe('error')
|
||||
expect(files.value[0].error).toBe('Upload failed (500)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rename after upload', () => {
|
||||
it('calls POST /api/v1/books/:id/rename-files after all uploads complete when renameAfter=true and some succeeded', async () => {
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42, { renameAfter: true })
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 201
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/v1/books/42/rename-files', expect.objectContaining({ method: 'POST' }))
|
||||
})
|
||||
|
||||
it('does not call rename endpoint when renameAfter=false', async () => {
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42, { renameAfter: false })
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call rename endpoint by default (renameAfter omitted)', async () => {
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42)
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call rename endpoint when renameAfter=true but no uploads succeeded', async () => {
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42, { renameAfter: true })
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 409
|
||||
xhr.responseText = JSON.stringify({ message: 'Already exists' })
|
||||
xhr.onload?.()
|
||||
await uploadPromise
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fail startUpload if rename endpoint throws', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { addFiles, startUpload } = useAddBookFile()
|
||||
addFiles([makeFile('book.epub')])
|
||||
|
||||
const uploadPromise = startUpload(42, { renameAfter: true })
|
||||
const xhr = mockXhrInstances[0]
|
||||
xhr.status = 201
|
||||
xhr.onload?.()
|
||||
|
||||
await expect(uploadPromise).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { getAccessToken } from '@/lib/api'
|
||||
import type { AddBookFileResult } from '@bookorbit/types'
|
||||
|
||||
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
|
||||
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
|
||||
export const MAX_FILE_BYTES = 500 * 1024 * 1024
|
||||
|
||||
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error'
|
||||
|
||||
export interface BookFileUploadItem {
|
||||
id: string
|
||||
file: File
|
||||
status: FileUploadStatus
|
||||
progress: number
|
||||
error?: string
|
||||
result?: AddBookFileResult
|
||||
validationError?: boolean
|
||||
}
|
||||
|
||||
const UPLOAD_CONCURRENCY = 3
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (!SUPPORTED_FORMATS.includes(ext)) {
|
||||
return `Unsupported type .${ext}. Allowed: ${SUPPORTED_FORMATS.join(', ')}`
|
||||
}
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
return `File exceeds the 500 MB limit`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function uploadSingle(item: BookFileUploadItem, bookId: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', item.file)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', `/api/v1/books/${bookId}/files`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
item.progress = Math.round((e.loaded / e.total) * 100)
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 201) {
|
||||
item.status = 'done'
|
||||
item.progress = 100
|
||||
try {
|
||||
item.result = JSON.parse(xhr.responseText) as AddBookFileResult
|
||||
} catch {
|
||||
// result is optional — upload still counts as done
|
||||
}
|
||||
} else {
|
||||
item.status = 'error'
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText)
|
||||
item.error = body.message ?? 'Upload failed'
|
||||
} catch {
|
||||
item.error = `Upload failed (${xhr.status})`
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
item.status = 'error'
|
||||
item.error = 'Network error'
|
||||
resolve()
|
||||
}
|
||||
|
||||
item.status = 'uploading'
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
async function triggerRename(bookId: number): Promise<void> {
|
||||
const token = getAccessToken()
|
||||
await fetch(`/api/v1/books/${bookId}/rename-files`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
}).catch(() => {
|
||||
// Best-effort — rename failure does not invalidate completed uploads
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddBookFile() {
|
||||
const files = ref<BookFileUploadItem[]>([])
|
||||
|
||||
const pendingCount = computed(() => files.value.filter((f) => f.status === 'pending').length)
|
||||
const isUploading = computed(() => files.value.some((f) => f.status === 'uploading'))
|
||||
const doneCount = computed(() => files.value.filter((f) => f.status === 'done').length)
|
||||
const errorCount = computed(() => files.value.filter((f) => f.status === 'error').length)
|
||||
|
||||
function addFiles(incoming: File[]) {
|
||||
for (const file of incoming) {
|
||||
if (files.value.some((f) => f.file.name === file.name && f.file.size === file.size)) continue
|
||||
|
||||
const error = validateFile(file)
|
||||
files.value.push({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}`,
|
||||
file,
|
||||
status: error ? 'error' : 'pending',
|
||||
progress: 0,
|
||||
error: error ?? undefined,
|
||||
validationError: error !== null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(id: string) {
|
||||
files.value = files.value.filter((f) => f.id !== id)
|
||||
}
|
||||
|
||||
function retryFile(id: string) {
|
||||
const item = files.value.find((f) => f.id === id)
|
||||
if (!item || item.status !== 'error' || item.validationError) return
|
||||
item.status = 'pending'
|
||||
item.error = undefined
|
||||
item.progress = 0
|
||||
}
|
||||
|
||||
function reset() {
|
||||
files.value = []
|
||||
}
|
||||
|
||||
async function startUpload(bookId: number, options: { renameAfter?: boolean } = {}) {
|
||||
const pending = files.value.filter((f) => f.status === 'pending')
|
||||
if (pending.length === 0) return
|
||||
|
||||
const renameAfter = options.renameAfter ?? false
|
||||
let index = 0
|
||||
async function runNext(): Promise<void> {
|
||||
const item = pending[index++]
|
||||
if (!item) return
|
||||
await uploadSingle(item, bookId)
|
||||
await runNext()
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, pending.length) }, () => runNext())
|
||||
await Promise.all(workers)
|
||||
|
||||
if (renameAfter && doneCount.value > 0) {
|
||||
await triggerRename(bookId)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
pendingCount,
|
||||
isUploading,
|
||||
doneCount,
|
||||
errorCount,
|
||||
addFiles,
|
||||
removeFile,
|
||||
retryFile,
|
||||
reset,
|
||||
startUpload,
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
|
||||
@locks-changed="onLocksChanged"
|
||||
@cover-changed="onCoverChanged"
|
||||
/>
|
||||
<FilesTab v-else-if="tab === 'files'" :book="detail" />
|
||||
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" />
|
||||
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" />
|
||||
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"],
|
||||
|
||||
// noUncheckedIndexedAccess produces false positives in tests where the
|
||||
// test author controls the data and index bounds are known.
|
||||
"noUncheckedIndexedAccess": false,
|
||||
|
||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||
// Specified here to keep it out of the root directory.
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
|
||||
|
||||
@@ -4,3 +4,15 @@ export type UploadResult = {
|
||||
format: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
export type AddBookFileResult = {
|
||||
id: number;
|
||||
format: string | null;
|
||||
role: string;
|
||||
sizeBytes: number | null;
|
||||
absolutePath: string;
|
||||
createdAt: string;
|
||||
filename: string;
|
||||
durationSeconds: number | null;
|
||||
bookStatus: string;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { PERMISSION_KEY } from '../../common/decorators/require-permission.decorator';
|
||||
|
||||
import { BookFileUploadController } from './book-file-upload.controller';
|
||||
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
|
||||
|
||||
function makeUser(overrides: Record<string, unknown> = {}) {
|
||||
return { id: 1, isSuperuser: false, permissions: [], ...overrides } as any;
|
||||
}
|
||||
|
||||
describe('BookFileUploadController', () => {
|
||||
const uploadService = {
|
||||
addFileToBook: vi.fn(),
|
||||
renameBookFiles: vi.fn(),
|
||||
};
|
||||
|
||||
const controller = new BookFileUploadController(uploadService as any);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('addFileToBook', () => {
|
||||
it('requires LibraryUpload permission', () => {
|
||||
const permission = Reflect.getMetadata(PERMISSION_KEY, BookFileUploadController.prototype.addFileToBook);
|
||||
expect(permission).toBe(Permission.LibraryUpload);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when no multipart file is present', async () => {
|
||||
const req = { file: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(controller.addFileToBook(1, makeUser(), req as any)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(uploadService.addFileToBook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes 500 MB file size limit to multipart parser', async () => {
|
||||
const stream = {};
|
||||
const req = { file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: stream }) };
|
||||
uploadService.addFileToBook.mockResolvedValue({ id: 1 });
|
||||
|
||||
await controller.addFileToBook(5, makeUser(), req as any);
|
||||
|
||||
expect(req.file).toHaveBeenCalledWith({ limits: { fileSize: MAX_UPLOAD_BYTES } });
|
||||
});
|
||||
|
||||
it('delegates to uploadService.addFileToBook with correct arguments', async () => {
|
||||
const stream = {};
|
||||
const user = makeUser({ id: 3 });
|
||||
const req = { file: vi.fn().mockResolvedValue({ filename: 'dune.epub', file: stream }) };
|
||||
const serviceResult = {
|
||||
id: 55,
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
sizeBytes: 1024,
|
||||
absolutePath: '/library/Book/dune.epub',
|
||||
createdAt: '2025-01-01T00:00:00.000Z',
|
||||
filename: 'dune.epub',
|
||||
durationSeconds: null,
|
||||
bookStatus: 'present',
|
||||
};
|
||||
uploadService.addFileToBook.mockResolvedValue(serviceResult);
|
||||
|
||||
const result = await controller.addFileToBook(7, user, req as any);
|
||||
|
||||
expect(uploadService.addFileToBook).toHaveBeenCalledWith(7, 'dune.epub', stream, user);
|
||||
expect(result).toEqual(serviceResult);
|
||||
});
|
||||
|
||||
it('propagates NotFoundException from the service', async () => {
|
||||
const req = { file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: {} }) };
|
||||
uploadService.addFileToBook.mockRejectedValue(new NotFoundException('Book 999 not found'));
|
||||
|
||||
await expect(controller.addFileToBook(999, makeUser(), req as any)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('propagates ForbiddenException from the service', async () => {
|
||||
const req = { file: vi.fn().mockResolvedValue({ filename: 'book.epub', file: {} }) };
|
||||
uploadService.addFileToBook.mockRejectedValue(new ForbiddenException('No access'));
|
||||
|
||||
await expect(controller.addFileToBook(1, makeUser(), req as any)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameBookFiles', () => {
|
||||
it('requires LibraryEditMetadata permission', () => {
|
||||
const permission = Reflect.getMetadata(PERMISSION_KEY, BookFileUploadController.prototype.renameBookFiles);
|
||||
expect(permission).toBe(Permission.LibraryEditMetadata);
|
||||
});
|
||||
|
||||
it('delegates to uploadService.renameBookFiles', async () => {
|
||||
const user = makeUser({ id: 5 });
|
||||
uploadService.renameBookFiles.mockResolvedValue(undefined);
|
||||
|
||||
const result = await controller.renameBookFiles(7, user);
|
||||
|
||||
expect(uploadService.renameBookFiles).toHaveBeenCalledWith(7, user);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('propagates NotFoundException from the service', async () => {
|
||||
uploadService.renameBookFiles.mockRejectedValue(new NotFoundException('Book not found'));
|
||||
|
||||
await expect(controller.renameBookFiles(999, makeUser())).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('propagates ForbiddenException from the service', async () => {
|
||||
uploadService.renameBookFiles.mockRejectedValue(new ForbiddenException('No access'));
|
||||
|
||||
await expect(controller.renameBookFiles(1, makeUser())).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BadRequestException, Controller, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Req } from '@nestjs/common';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { MultipartRequest } from '../../common/types/multipart-request';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
@Controller('books')
|
||||
export class BookFileUploadController {
|
||||
constructor(private readonly uploadService: UploadService) {}
|
||||
|
||||
@Post(':id/files')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.LibraryUpload)
|
||||
async addFileToBook(@Param('id', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser, @Req() req: MultipartRequest) {
|
||||
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
|
||||
if (!data) throw new BadRequestException('No file provided');
|
||||
return this.uploadService.addFileToBook(bookId, data.filename, data.file, user);
|
||||
}
|
||||
|
||||
@Post(':id/rename-files')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@RequirePermission(Permission.LibraryEditMetadata)
|
||||
async renameBookFiles(@Param('id', ParseIntPipe) bookId: number, @CurrentUser() user: RequestUser): Promise<void> {
|
||||
await this.uploadService.renameBookFiles(bookId, user);
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,33 @@ import 'reflect-metadata';
|
||||
vi.mock('../app-settings/app-settings.module', () => ({ AppSettingsModule: class AppSettingsModule {} }));
|
||||
vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} }));
|
||||
vi.mock('../metadata/metadata.module', () => ({ MetadataModule: class MetadataModule {} }));
|
||||
vi.mock('../file-write/file-write.module', () => ({ FileWriteModule: class FileWriteModule {} }));
|
||||
vi.mock('../book-metadata-fetch/book-metadata-fetch.module', () => ({ BookMetadataFetchModule: class BookMetadataFetchModule {} }));
|
||||
|
||||
import { BookFileUploadController } from './book-file-upload.controller';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadModule } from './upload.module';
|
||||
import { UploadProcessorService } from './upload-processor.service';
|
||||
import { UploadService } from './upload.service';
|
||||
import { UploadStorageService } from './upload-storage.service';
|
||||
import { UploadValidatorService } from './upload-validator.service';
|
||||
import { FileWriteModule } from '../file-write/file-write.module';
|
||||
|
||||
describe('UploadModule', () => {
|
||||
it('registers expected controller/providers/exports', () => {
|
||||
expect(Reflect.getMetadata('controllers', UploadModule)).toEqual([UploadController]);
|
||||
expect(Reflect.getMetadata('controllers', UploadModule)).toEqual([UploadController, BookFileUploadController]);
|
||||
expect(Reflect.getMetadata('providers', UploadModule)).toEqual([
|
||||
UploadService,
|
||||
UploadValidatorService,
|
||||
UploadStorageService,
|
||||
UploadProcessorService,
|
||||
]);
|
||||
expect(Reflect.getMetadata('exports', UploadModule)).toEqual([UploadValidatorService, UploadStorageService, UploadProcessorService]);
|
||||
expect(Reflect.getMetadata('exports', UploadModule)).toEqual([
|
||||
UploadValidatorService,
|
||||
UploadStorageService,
|
||||
UploadProcessorService,
|
||||
UploadService,
|
||||
]);
|
||||
expect(Reflect.getMetadata('imports', UploadModule)).toContain(FileWriteModule);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
||||
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { BookMetadataFetchModule } from '../book-metadata-fetch/book-metadata-fetch.module';
|
||||
import { FileWriteModule } from '../file-write/file-write.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
import { MetadataModule } from '../metadata/metadata.module';
|
||||
import { BookFileUploadController } from './book-file-upload.controller';
|
||||
import { UploadController } from './upload.controller';
|
||||
import { UploadProcessorService } from './upload-processor.service';
|
||||
import { UploadService } from './upload.service';
|
||||
@@ -11,9 +13,9 @@ import { UploadStorageService } from './upload-storage.service';
|
||||
import { UploadValidatorService } from './upload-validator.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppSettingsModule, LibraryModule, MetadataModule, BookMetadataFetchModule],
|
||||
controllers: [UploadController],
|
||||
imports: [AppSettingsModule, LibraryModule, MetadataModule, BookMetadataFetchModule, FileWriteModule],
|
||||
controllers: [UploadController, BookFileUploadController],
|
||||
providers: [UploadService, UploadValidatorService, UploadStorageService, UploadProcessorService],
|
||||
exports: [UploadValidatorService, UploadStorageService, UploadProcessorService],
|
||||
exports: [UploadValidatorService, UploadStorageService, UploadProcessorService, UploadService],
|
||||
})
|
||||
export class UploadModule {}
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
vi.mock('fs/promises', async () => {
|
||||
const actual = await vi.importActual('fs/promises');
|
||||
return { ...actual, access: vi.fn() };
|
||||
return { ...actual, access: vi.fn(), stat: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() }));
|
||||
vi.mock('../scanner/lib/walk', () => ({ clampIno: vi.fn() }));
|
||||
|
||||
vi.mock('../metadata/lib/epub', () => ({ extractEpubMetadata: vi.fn() }));
|
||||
vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), extractCbrMetadata: vi.fn(), extractCb7Metadata: vi.fn() }));
|
||||
vi.mock('../metadata/lib/mobi-parser', () => ({ parseMobiFile: vi.fn() }));
|
||||
vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() }));
|
||||
|
||||
import { access as fsAccess } from 'fs/promises';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { access as fsAccess, stat } from 'fs/promises';
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { extractEpubMetadata } from '../metadata/lib/epub';
|
||||
import { extractCbzMetadata } from '../metadata/lib/cbz-metadata';
|
||||
import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile } from '../metadata/lib/pdf-parser';
|
||||
import { computeFileHash } from '../scanner/lib/hash';
|
||||
import { clampIno } from '../scanner/lib/walk';
|
||||
|
||||
import { UploadService } from './upload.service';
|
||||
|
||||
const mockFsAccess = fsAccess as MockedFunction<typeof fsAccess>;
|
||||
const mockStat = stat as MockedFunction<typeof stat>;
|
||||
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
|
||||
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
|
||||
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
|
||||
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
|
||||
const mockComputeFileHash = computeFileHash as MockedFunction<typeof computeFileHash>;
|
||||
const mockClampIno = clampIno as MockedFunction<typeof clampIno>;
|
||||
|
||||
function selectChain(rows: unknown[]) {
|
||||
const whereResult: PromiseLike<unknown[]> & { limit: vi.Mock } = {
|
||||
@@ -37,8 +45,15 @@ function selectChain(rows: unknown[]) {
|
||||
}
|
||||
|
||||
describe('UploadService', () => {
|
||||
const insertReturning = vi.fn();
|
||||
const insertValues = vi.fn();
|
||||
const updateSet = vi.fn();
|
||||
const updateWhere = vi.fn();
|
||||
|
||||
const db = {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(() => ({ values: insertValues })),
|
||||
update: vi.fn(() => ({ set: updateSet })),
|
||||
};
|
||||
|
||||
const appSettings = { getUploadPattern: vi.fn(), getUploadPatternBookPerFolder: vi.fn(), isCrossPlatformPathSanitizationEnabled: vi.fn() };
|
||||
@@ -59,6 +74,8 @@ describe('UploadService', () => {
|
||||
|
||||
const user = { id: 7, isSuperuser: false, permissions: [] } as any;
|
||||
|
||||
const moduleRef = { get: vi.fn().mockReturnValue(null) };
|
||||
|
||||
let service: UploadService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -67,7 +84,15 @@ describe('UploadService', () => {
|
||||
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
|
||||
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
service = new UploadService(db as any, appSettings as any, libraryService as any, validator as any, storage as any, processor as any);
|
||||
service = new UploadService(
|
||||
db as any,
|
||||
appSettings as any,
|
||||
libraryService as any,
|
||||
validator as any,
|
||||
storage as any,
|
||||
processor as any,
|
||||
moduleRef as any,
|
||||
);
|
||||
|
||||
validator.sanitizeFilename.mockReturnValue('book.epub');
|
||||
validator.validateFormat.mockReturnValue('epub');
|
||||
@@ -81,6 +106,25 @@ describe('UploadService', () => {
|
||||
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false);
|
||||
mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
mockExtractEpubMetadata.mockResolvedValue(null);
|
||||
|
||||
// addFileToBook defaults
|
||||
insertValues.mockReturnValue({ returning: insertReturning });
|
||||
insertReturning.mockResolvedValue([
|
||||
{
|
||||
id: 55,
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
sizeBytes: 456,
|
||||
absolutePath: '/library/Book/book.epub',
|
||||
createdAt: new Date('2025-01-01'),
|
||||
durationSeconds: null,
|
||||
},
|
||||
]);
|
||||
updateSet.mockReturnValue({ where: updateWhere });
|
||||
updateWhere.mockResolvedValue(undefined);
|
||||
mockComputeFileHash.mockResolvedValue('hash-abc');
|
||||
mockClampIno.mockReturnValue(12345);
|
||||
mockStat.mockResolvedValue({ ino: 12345n, mtime: new Date('2025-01-01') } as any);
|
||||
});
|
||||
|
||||
it('uploads successfully and kicks off async metadata extraction', async () => {
|
||||
@@ -650,4 +694,220 @@ describe('UploadService', () => {
|
||||
expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 });
|
||||
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub');
|
||||
});
|
||||
|
||||
describe('addFileToBook', () => {
|
||||
function makeBookRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 10,
|
||||
folderPath: '/library/Book Title',
|
||||
libraryId: 1,
|
||||
libraryFolderId: 2,
|
||||
primaryFileId: 99,
|
||||
status: 'present',
|
||||
allowedFormats: [] as string[],
|
||||
organizationMode: 'book_per_folder',
|
||||
libraryFolderPath: '/library',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function selectJoinChain(rows: unknown[]) {
|
||||
const limitMock = vi.fn().mockResolvedValue(rows);
|
||||
const whereMock = vi.fn().mockReturnValue({ limit: limitMock });
|
||||
const joinMock2 = vi.fn().mockReturnValue({ where: whereMock });
|
||||
const joinMock1 = vi.fn().mockReturnValue({ innerJoin: joinMock2, where: whereMock });
|
||||
return {
|
||||
from: vi.fn().mockReturnValue({ innerJoin: joinMock1 }),
|
||||
};
|
||||
}
|
||||
|
||||
function noHashConflict() {
|
||||
return selectChain([]);
|
||||
}
|
||||
|
||||
it('adds a file successfully to an existing book with a primary file', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
|
||||
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
|
||||
|
||||
expect(storage.streamToTemp).toHaveBeenCalled();
|
||||
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Book Title/book.epub');
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
expect(result.filename).toBe('book.epub');
|
||||
expect(result.format).toBe('epub');
|
||||
expect(result.bookStatus).toBe('present');
|
||||
expect(result.role).toBe('content');
|
||||
});
|
||||
|
||||
it('promotes new file to primary when book has no primary file', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null })])).mockReturnValueOnce(noHashConflict());
|
||||
|
||||
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(updateWhere).toHaveBeenCalled();
|
||||
expect(result.role).toBe('primary');
|
||||
});
|
||||
|
||||
it('updates book status from missing to present and promotes primary when null', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null, status: 'missing' })])).mockReturnValueOnce(noHashConflict());
|
||||
|
||||
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(result.bookStatus).toBe('present');
|
||||
expect(result.role).toBe('primary');
|
||||
});
|
||||
|
||||
it('updates only status when book is missing but already has a primary file', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: 42, status: 'missing' })])).mockReturnValueOnce(noHashConflict());
|
||||
|
||||
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
|
||||
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
expect(result.bookStatus).toBe('present');
|
||||
expect(result.role).toBe('content');
|
||||
});
|
||||
|
||||
it('does not call db.update when book is present and already has a primary', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
|
||||
await service.addFileToBook(10, 'book.epub', {} as any, user);
|
||||
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException when book does not exist', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([]));
|
||||
|
||||
await expect(service.addFileToBook(999, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(storage.streamToTemp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user has no library access', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
|
||||
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(storage.streamToTemp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException for book_per_file organization mode', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ organizationMode: 'book_per_file' })]));
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(storage.streamToTemp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException when format validation fails', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
|
||||
validator.validateFormat.mockImplementation(() => {
|
||||
throw new BadRequestException('Unsupported format');
|
||||
});
|
||||
|
||||
await expect(service.addFileToBook(10, 'bad.xyz', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(storage.streamToTemp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws BadRequestException for empty file and cleans up temp', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
|
||||
storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 0 });
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
|
||||
});
|
||||
|
||||
it('throws ConflictException for duplicate content hash and cleans up temp', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(selectChain([{ id: 77 }]));
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
|
||||
expect(storage.moveToPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws ConflictException when destination file already exists and cleans up temp', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
mockFsAccess.mockResolvedValue(undefined);
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
|
||||
expect(storage.moveToPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cleans up only temp when DB insert fails after move (file remains for scanner)', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
insertReturning.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('DB error');
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
|
||||
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/Book Title/book.epub');
|
||||
});
|
||||
|
||||
it('cleans up only temp when move fails (destination not yet written)', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
storage.moveToPath.mockRejectedValue(new Error('disk full'));
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('disk full');
|
||||
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
|
||||
});
|
||||
|
||||
it('propagates access errors other than ENOENT from destinationExists check', async () => {
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
mockFsAccess.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
|
||||
|
||||
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('permission denied');
|
||||
expect(storage.moveToPath).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns correct createdAt as ISO string', async () => {
|
||||
const date = new Date('2025-06-01T12:00:00.000Z');
|
||||
insertReturning.mockResolvedValue([
|
||||
{
|
||||
id: 55,
|
||||
format: 'epub',
|
||||
role: 'content',
|
||||
sizeBytes: 456,
|
||||
absolutePath: '/library/Book Title/book.epub',
|
||||
createdAt: date,
|
||||
durationSeconds: null,
|
||||
},
|
||||
]);
|
||||
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
|
||||
|
||||
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
|
||||
|
||||
expect(result.createdAt).toBe(date.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('renameBookFiles', () => {
|
||||
it('calls performRename with force=true', async () => {
|
||||
const mockRenameService = { performRename: vi.fn().mockResolvedValue({ status: 'success', durationMs: 50 }) };
|
||||
moduleRef.get.mockReturnValue(mockRenameService);
|
||||
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
|
||||
|
||||
await expect(service.renameBookFiles(10, user)).resolves.toBeUndefined();
|
||||
expect(mockRenameService.performRename).toHaveBeenCalledWith(10, user.id, true, false);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when book does not exist', async () => {
|
||||
db.select.mockReturnValueOnce(selectChain([]));
|
||||
|
||||
await expect(service.renameBookFiles(999, user)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('throws ForbiddenException when user has no library access', async () => {
|
||||
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
|
||||
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
|
||||
|
||||
await expect(service.renameBookFiles(10, user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
it('throws ServiceUnavailableException when rename service is unavailable', async () => {
|
||||
moduleRef.get.mockReturnValue(null);
|
||||
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
|
||||
|
||||
await expect(service.renameBookFiles(10, user)).rejects.toThrow(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { access as fsAccess } from 'fs/promises';
|
||||
import { basename, dirname, extname, join } from 'path';
|
||||
import { BadRequestException, ConflictException, Inject, Injectable, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { access as fsAccess, stat } from 'fs/promises';
|
||||
import { basename, dirname, extname, join, relative } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
import { libraries, libraryFolders } from '../../db/schema';
|
||||
import { books, bookFiles, libraries, libraryFolders } from '../../db/schema';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
import { UploadValidatorService } from './upload-validator.service';
|
||||
import { UploadStorageService } from './upload-storage.service';
|
||||
import { UploadProcessorService } from './upload-processor.service';
|
||||
import { FileRenameService } from '../file-write/file-rename.service';
|
||||
import { resolveDownloadFilename, resolveUploadPath } from '@bookorbit/types';
|
||||
import type { UploadResult } from '@bookorbit/types';
|
||||
import type { AddBookFileResult, UploadResult } from '@bookorbit/types';
|
||||
import { extractEpubMetadata } from '../metadata/lib/epub';
|
||||
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
|
||||
import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
|
||||
import { computeFileHash } from '../scanner/lib/hash';
|
||||
import { clampIno } from '../scanner/lib/walk';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
@@ -35,8 +39,17 @@ export class UploadService {
|
||||
private readonly validator: UploadValidatorService,
|
||||
private readonly storage: UploadStorageService,
|
||||
private readonly processor: UploadProcessorService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
private resolveFileRenameService(): FileRenameService | null {
|
||||
try {
|
||||
return this.moduleRef.get(FileRenameService, { strict: false });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async upload(libraryId: number, folderId: number | undefined, rawFilename: string, fileStream: Readable, user: RequestUser): Promise<UploadResult> {
|
||||
const event = 'upload.book';
|
||||
const startedAt = Date.now();
|
||||
@@ -89,6 +102,175 @@ export class UploadService {
|
||||
}
|
||||
}
|
||||
|
||||
async addFileToBook(bookId: number, rawFilename: string, fileStream: Readable, user: RequestUser): Promise<AddBookFileResult> {
|
||||
const event = 'book.add_file';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(
|
||||
`[${event}] [start] bookId=${bookId} userId=${user.id} rawFilename="${sanitizeLogValue(rawFilename)}" - add file to book started`,
|
||||
);
|
||||
|
||||
const [bookRow] = await this.db
|
||||
.select({
|
||||
id: books.id,
|
||||
folderPath: books.folderPath,
|
||||
libraryId: books.libraryId,
|
||||
libraryFolderId: books.libraryFolderId,
|
||||
primaryFileId: books.primaryFileId,
|
||||
status: books.status,
|
||||
allowedFormats: libraries.allowedFormats,
|
||||
organizationMode: libraries.organizationMode,
|
||||
libraryFolderPath: libraryFolders.path,
|
||||
})
|
||||
.from(books)
|
||||
.innerJoin(libraries, eq(books.libraryId, libraries.id))
|
||||
.innerJoin(libraryFolders, eq(books.libraryFolderId, libraryFolders.id))
|
||||
.where(eq(books.id, bookId))
|
||||
.limit(1);
|
||||
|
||||
if (!bookRow) throw new NotFoundException(`Book ${bookId} not found`);
|
||||
|
||||
await this.libraryService.verifyUserAccess(user.id, bookRow.libraryId, user.isSuperuser);
|
||||
|
||||
if (bookRow.organizationMode === 'book_per_file') {
|
||||
throw new BadRequestException('Cannot add files to a book in a single-file library. Upload a new book instead.');
|
||||
}
|
||||
|
||||
const filename = this.validator.sanitizeFilename(rawFilename);
|
||||
const format = this.validator.validateFormat(filename, bookRow.allowedFormats);
|
||||
|
||||
const { tempPath, sizeBytes } = await this.storage.streamToTemp(fileStream);
|
||||
|
||||
if (sizeBytes === 0) {
|
||||
await this.storage.cleanup(tempPath);
|
||||
throw new BadRequestException('File must not be empty');
|
||||
}
|
||||
|
||||
let destination: string | null = null;
|
||||
let shouldCleanupDestination = false;
|
||||
|
||||
try {
|
||||
const fileHash = await computeFileHash(tempPath);
|
||||
|
||||
const [existingWithHash] = await this.db
|
||||
.select({ id: bookFiles.id })
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.bookId, bookId), eq(bookFiles.fileHash, fileHash)))
|
||||
.limit(1);
|
||||
|
||||
if (existingWithHash) {
|
||||
throw new ConflictException('This file is already attached to this book');
|
||||
}
|
||||
|
||||
destination = join(bookRow.folderPath, filename);
|
||||
|
||||
if (await this.destinationExists(destination)) {
|
||||
throw new ConflictException(`A file named "${filename}" already exists in this book's folder`);
|
||||
}
|
||||
|
||||
shouldCleanupDestination = true;
|
||||
await this.storage.moveToPath(tempPath, destination);
|
||||
// File is on disk. Do not delete it on any subsequent failure — the scanner
|
||||
// will reconcile any orphan. This also prevents the catch block from deleting
|
||||
// a file that a concurrent upload may have written to the same path.
|
||||
shouldCleanupDestination = false;
|
||||
|
||||
const fileStat = await stat(destination, { bigint: true });
|
||||
const safeIno = clampIno(fileStat.ino);
|
||||
const relPath = relative(bookRow.libraryFolderPath, destination);
|
||||
|
||||
const [inserted] = await this.db
|
||||
.insert(bookFiles)
|
||||
.values({
|
||||
bookId,
|
||||
libraryFolderId: bookRow.libraryFolderId,
|
||||
absolutePath: destination,
|
||||
relPath,
|
||||
ino: safeIno,
|
||||
sizeBytes,
|
||||
mtime: fileStat.mtime,
|
||||
fileHash,
|
||||
format,
|
||||
role: 'content',
|
||||
})
|
||||
.returning({
|
||||
id: bookFiles.id,
|
||||
format: bookFiles.format,
|
||||
role: bookFiles.role,
|
||||
sizeBytes: bookFiles.sizeBytes,
|
||||
absolutePath: bookFiles.absolutePath,
|
||||
createdAt: bookFiles.createdAt,
|
||||
durationSeconds: bookFiles.durationSeconds,
|
||||
});
|
||||
|
||||
if (!inserted) throw new Error('Failed to insert book file record');
|
||||
|
||||
let finalStatus = bookRow.status;
|
||||
const needsPrimaryPromotion = bookRow.primaryFileId === null;
|
||||
const needsStatusUpdate = bookRow.status === 'missing';
|
||||
|
||||
if (needsPrimaryPromotion || needsStatusUpdate) {
|
||||
await this.db
|
||||
.update(books)
|
||||
.set({
|
||||
...(needsPrimaryPromotion ? { primaryFileId: inserted.id } : {}),
|
||||
...(needsStatusUpdate ? { status: 'present' } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(books.id, bookId));
|
||||
|
||||
if (needsStatusUpdate) finalStatus = 'present';
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[${event}] [end] bookId=${bookId} userId=${user.id} fileId=${inserted.id} format=${format} sizeBytes=${sizeBytes} durationMs=${Date.now() - startedAt} - add file to book completed`,
|
||||
);
|
||||
|
||||
return {
|
||||
id: inserted.id,
|
||||
format: inserted.format,
|
||||
role: needsPrimaryPromotion ? 'primary' : inserted.role,
|
||||
sizeBytes: inserted.sizeBytes,
|
||||
absolutePath: inserted.absolutePath,
|
||||
createdAt: inserted.createdAt.toISOString(),
|
||||
filename: basename(destination),
|
||||
durationSeconds: inserted.durationSeconds,
|
||||
bookStatus: finalStatus,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - add file to book failed`,
|
||||
);
|
||||
await Promise.allSettled([
|
||||
this.storage.cleanup(tempPath),
|
||||
shouldCleanupDestination && destination ? this.storage.cleanup(destination) : Promise.resolve(),
|
||||
]);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async renameBookFiles(bookId: number, user: RequestUser): Promise<void> {
|
||||
const event = 'book.rename_files';
|
||||
const startedAt = Date.now();
|
||||
this.logger.log(`[${event}] [start] bookId=${bookId} userId=${user.id} - rename book files started`);
|
||||
|
||||
const [bookRow] = await this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(eq(books.id, bookId)).limit(1);
|
||||
|
||||
if (!bookRow) throw new NotFoundException(`Book ${bookId} not found`);
|
||||
|
||||
await this.libraryService.verifyUserAccess(user.id, bookRow.libraryId, user.isSuperuser);
|
||||
|
||||
const fileRenameService = this.resolveFileRenameService();
|
||||
if (!fileRenameService) {
|
||||
throw new ServiceUnavailableException('File rename service is not available');
|
||||
}
|
||||
|
||||
await fileRenameService.performRename(bookId, user.id, true, false);
|
||||
|
||||
this.logger.log(`[${event}] [end] bookId=${bookId} userId=${user.id} durationMs=${Date.now() - startedAt} - rename book files completed`);
|
||||
}
|
||||
|
||||
private async resolveDestination(
|
||||
library: { fileNamingPattern?: string | null; organizationMode?: string | null },
|
||||
libraryFolderPath: string,
|
||||
|
||||
Reference in New Issue
Block a user