mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): report failed Book Dock uploads
The Book Dock discarded every upload failure, so a rejected file looked like nothing happened. List failed files with a translated reason, offer retry where it can help, and stop a failed token refresh, abort or timeout from stalling the upload queue. Dropped files of unsupported types are reported instead of ignored. Refs #1739
This commit is contained in:
@@ -19,7 +19,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isUploading, addFiles, clearCompleted } = useBookDockUpload()
|
||||
const { isUploading, addFiles } = useBookDockUpload()
|
||||
const { hasPermission } = usePermissions()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -29,7 +29,6 @@ const processingStateChanging = ref(false)
|
||||
const canManageBookDock = computed(() => hasPermission(Permission.ManageBookDock))
|
||||
|
||||
function openFilePicker() {
|
||||
clearCompleted()
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { AlertCircle, RotateCw, X } from '@lucide/vue'
|
||||
import { useBookDockUpload } from '../composables/useBookDockUpload'
|
||||
import { describeUploadFailure, isRetryableUploadFailure } from '../lib/upload-failure'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { failures, retryFailure, dismissFailure, dismissAllFailures } = useBookDockUpload()
|
||||
|
||||
const rows = computed(() =>
|
||||
failures.value.map((item) => {
|
||||
const reason = item.failure ? describeUploadFailure(item.failure) : { key: 'bookDock.uploadFailures.reason.network' }
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.file.name,
|
||||
reason: 'text' in reason ? reason.text : t(reason.key, reason.params ?? {}),
|
||||
retryable: item.failure ? isRetryableUploadFailure(item.failure) : false,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
function handleRetry(id: string) {
|
||||
retryFailure(id)
|
||||
}
|
||||
|
||||
function handleDismiss(id: string) {
|
||||
dismissFailure(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="rows.length > 0"
|
||||
aria-labelledby="book-dock-upload-failures-heading"
|
||||
data-testid="book-dock-upload-failures"
|
||||
class="overflow-hidden rounded-xl border border-destructive/30 bg-destructive/5"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-4 py-2.5">
|
||||
<AlertCircle class="size-4 shrink-0 text-destructive" aria-hidden="true" />
|
||||
<h2 id="book-dock-upload-failures-heading" class="min-w-0 flex-1 text-sm font-medium text-foreground">
|
||||
<span role="alert">{{ t('bookDock.uploadFailures.heading', { count: rows.length }) }}</span>
|
||||
</h2>
|
||||
<button
|
||||
v-if="rows.length > 1"
|
||||
type="button"
|
||||
data-testid="book-dock-upload-failures-dismiss-all"
|
||||
class="h-7 shrink-0 rounded-lg px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
@click="dismissAllFailures"
|
||||
>
|
||||
{{ t('bookDock.uploadFailures.dismissAll') }}
|
||||
</button>
|
||||
</div>
|
||||
<ul class="max-h-64 divide-y divide-destructive/15 overflow-y-auto border-t border-destructive/20">
|
||||
<li v-for="row in rows" :key="row.id" class="flex items-center gap-3 px-4 py-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-foreground" :title="row.name">{{ row.name }}</p>
|
||||
<p class="text-xs text-destructive">{{ row.reason }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="row.retryable"
|
||||
type="button"
|
||||
class="grid size-7 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:title="t('bookDock.uploadFailures.retry', { name: row.name })"
|
||||
:aria-label="t('bookDock.uploadFailures.retry', { name: row.name })"
|
||||
@click="handleRetry(row.id)"
|
||||
>
|
||||
<RotateCw class="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="grid size-7 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:title="t('bookDock.uploadFailures.dismiss', { name: row.name })"
|
||||
:aria-label="t('bookDock.uploadFailures.dismiss', { name: row.name })"
|
||||
@click="handleDismiss(row.id)"
|
||||
>
|
||||
<X class="size-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,147 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import BookDockUploadFailures from '../BookDockUploadFailures.vue'
|
||||
import { useBookDockUpload } from '../../composables/useBookDockUpload'
|
||||
|
||||
const { getValidTokenMock } = vi.hoisted(() => ({ getValidTokenMock: vi.fn<() => Promise<string | null>>() }))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<() => Promise<Response>>(),
|
||||
getValidToken: getValidTokenMock,
|
||||
}))
|
||||
|
||||
class FakeXhr {
|
||||
static sent: FakeXhr[] = []
|
||||
status = 0
|
||||
responseText = ''
|
||||
upload = { onprogress: null }
|
||||
onloadend: (() => void) | null = null
|
||||
open() {}
|
||||
setRequestHeader() {}
|
||||
send() {
|
||||
FakeXhr.sent.push(this)
|
||||
}
|
||||
respond(status: number, body: object) {
|
||||
this.status = status
|
||||
this.responseText = JSON.stringify(body)
|
||||
this.onloadend?.()
|
||||
}
|
||||
}
|
||||
|
||||
function book(name: string): File {
|
||||
return new File([new Uint8Array(8)], name)
|
||||
}
|
||||
|
||||
async function failWithServerError(name: string, status: number, message: string) {
|
||||
const upload = useBookDockUpload()
|
||||
upload.addFiles([book(name)])
|
||||
await flushPromises()
|
||||
FakeXhr.sent.at(-1)?.respond(status, { statusCode: status, message, path: '/api/v1/book-dock/upload', timestamp: 'now', requestId: 'req-1' })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useBookDockUpload().clearAll()
|
||||
FakeXhr.sent = []
|
||||
getValidTokenMock.mockReset()
|
||||
getValidTokenMock.mockResolvedValue('token')
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('BookDockUploadFailures', () => {
|
||||
it('renders nothing while no upload has failed', () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
|
||||
expect(wrapper.find('[data-testid="book-dock-upload-failures"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('names each failed file with its reason', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
|
||||
useBookDockUpload().addFiles([book('notes.docx'), book('README')])
|
||||
await flushPromises()
|
||||
|
||||
const rows = wrapper.findAll('li').map((row) => row.text())
|
||||
expect(rows).toEqual([expect.stringContaining('notes.docx'), expect.stringContaining('README')])
|
||||
expect(rows[0]).toContain('Unsupported file type: .docx')
|
||||
expect(rows[1]).toContain('The file has no extension, so its format is unknown')
|
||||
})
|
||||
|
||||
it('shows the server message when the server gave a reason the client cannot translate', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
|
||||
await failWithServerError('dune.epub', 500, 'EACCES: permission denied, open /books/dune.epub')
|
||||
|
||||
expect(wrapper.get('li').text()).toContain('EACCES: permission denied, open /books/dune.epub')
|
||||
})
|
||||
|
||||
it('announces the failure count through an alert inside the heading', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
|
||||
useBookDockUpload().addFiles([book('a.docx')])
|
||||
await flushPromises()
|
||||
expect(wrapper.get('h2 [role="alert"]').text()).toBe('Could not upload 1 file')
|
||||
|
||||
useBookDockUpload().addFiles([book('b.docx')])
|
||||
await flushPromises()
|
||||
expect(wrapper.get('h2 [role="alert"]').text()).toBe('Could not upload 2 files')
|
||||
expect(wrapper.get('section').attributes('aria-labelledby')).toBe(wrapper.get('h2').attributes('id'))
|
||||
})
|
||||
|
||||
it('offers retry only where retrying can help', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
|
||||
useBookDockUpload().addFiles([book('notes.docx')])
|
||||
await failWithServerError('dune.epub', 503, 'Service unavailable')
|
||||
|
||||
expect(wrapper.findAll('button[aria-label^="Retry uploading"]').map((b) => b.attributes('aria-label'))).toEqual(['Retry uploading dune.epub'])
|
||||
})
|
||||
|
||||
it('re-sends the file when retry is pressed', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
await failWithServerError('dune.epub', 503, 'Service unavailable')
|
||||
|
||||
await wrapper.get('button[aria-label="Retry uploading dune.epub"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(2)
|
||||
expect(wrapper.find('[data-testid="book-dock-upload-failures"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('dismisses a single failure', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
useBookDockUpload().addFiles([book('a.docx'), book('b.docx')])
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('button[aria-label="Dismiss a.docx"]').trigger('click')
|
||||
|
||||
expect(wrapper.findAll('li').map((row) => row.find('p').text())).toEqual(['b.docx'])
|
||||
})
|
||||
|
||||
it('offers dismiss all only when more than one upload failed', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
useBookDockUpload().addFiles([book('a.docx')])
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-testid="book-dock-upload-failures-dismiss-all"]').exists()).toBe(false)
|
||||
|
||||
useBookDockUpload().addFiles([book('b.docx')])
|
||||
await flushPromises()
|
||||
await wrapper.get('[data-testid="book-dock-upload-failures-dismiss-all"]').trigger('click')
|
||||
|
||||
expect(wrapper.find('[data-testid="book-dock-upload-failures"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('gives every icon button an accessible name that includes the file', async () => {
|
||||
const wrapper = mount(BookDockUploadFailures)
|
||||
useBookDockUpload().addFiles([book('notes.docx')])
|
||||
await flushPromises()
|
||||
|
||||
const iconButtons = wrapper.findAll('li button')
|
||||
expect(iconButtons.map((b) => b.attributes('aria-label'))).toEqual(['Dismiss notes.docx'])
|
||||
expect(iconButtons.every((b) => b.attributes('type') === 'button')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,430 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getValidTokenMock } = vi.hoisted(() => ({ getValidTokenMock: vi.fn<() => Promise<string | null>>() }))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: vi.fn<() => Promise<Response>>(),
|
||||
getValidToken: getValidTokenMock,
|
||||
}))
|
||||
|
||||
const MB = 1024 * 1024
|
||||
|
||||
class FakeXhr {
|
||||
static sent: FakeXhr[] = []
|
||||
method = ''
|
||||
url = ''
|
||||
headers: Record<string, string> = {}
|
||||
body: FormData | null = null
|
||||
status = 0
|
||||
responseText = ''
|
||||
upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null }
|
||||
onloadend: (() => void) | null = null
|
||||
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
|
||||
setRequestHeader(name: string, value: string) {
|
||||
this.headers[name] = value
|
||||
}
|
||||
|
||||
send(body: FormData) {
|
||||
this.body = body
|
||||
FakeXhr.sent.push(this)
|
||||
}
|
||||
|
||||
respond(status: number, body: string | object) {
|
||||
this.status = status
|
||||
this.responseText = typeof body === 'string' ? body : JSON.stringify(body)
|
||||
this.onloadend?.()
|
||||
}
|
||||
|
||||
/** What a browser reports for a dropped connection, an abort and a timeout alike. */
|
||||
drop() {
|
||||
this.status = 0
|
||||
this.onloadend?.()
|
||||
}
|
||||
|
||||
progress(loaded: number, total: number) {
|
||||
this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent)
|
||||
}
|
||||
}
|
||||
|
||||
function bookOrbitError(statusCode: number, message: string, errorCode?: string) {
|
||||
return {
|
||||
statusCode,
|
||||
message,
|
||||
path: '/api/v1/book-dock/upload',
|
||||
timestamp: '2026-10-12T00:00:00.000Z',
|
||||
requestId: 'req-1',
|
||||
...(errorCode ? { errorCode } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function book(name: string, sizeBytes = 16): File {
|
||||
return new File([new Uint8Array(sizeBytes)], name, { type: 'application/epub+zip' })
|
||||
}
|
||||
|
||||
async function loadComposable() {
|
||||
const module = await import('../useBookDockUpload')
|
||||
const { useAppInfo } = await import('@/features/settings/composables/useAppInfo')
|
||||
return { upload: module.useBookDockUpload(), useBookDockUpload: module.useBookDockUpload, appInfo: useAppInfo() }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
FakeXhr.sent = []
|
||||
getValidTokenMock.mockReset()
|
||||
getValidTokenMock.mockResolvedValue('token')
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useBookDockUpload client-side validation', () => {
|
||||
it('records an unsupported file as a failure without sending it', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('notes.docx')])
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(0)
|
||||
expect(upload.failures.value.map((f) => f.failure)).toEqual([{ kind: 'unsupported', extension: 'docx' }])
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('records a file with no extension as unsupported', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('README')])
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'unsupported', extension: '' })
|
||||
})
|
||||
|
||||
it('accepts a supported extension regardless of case', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('Dune.EPUB')])
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(1)
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects a file over the configured upload limit before sending it', async () => {
|
||||
const { upload, appInfo } = await loadComposable()
|
||||
appInfo.maxUploadSizeMb.value = 1
|
||||
|
||||
upload.addFiles([book('huge.epub', 2 * MB)])
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(0)
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'tooLarge', limitBytes: MB })
|
||||
})
|
||||
|
||||
it('sends the valid files of a mixed selection and reports only the invalid one', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('good.epub'), book('cover.jpg')])
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(1)
|
||||
expect(upload.failures.value.map((f) => f.file.name)).toEqual(['cover.jpg'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBookDockUpload requests', () => {
|
||||
it('posts the file to the Book Dock with the session token', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
const file = book('dune.epub')
|
||||
|
||||
upload.addFiles([file])
|
||||
await flushPromises()
|
||||
|
||||
const [xhr] = FakeXhr.sent
|
||||
expect(xhr?.method).toBe('POST')
|
||||
expect(xhr?.url).toBe('/api/v1/book-dock/upload')
|
||||
expect(xhr?.headers.Authorization).toBe('Bearer token')
|
||||
expect(xhr?.body?.get('file')).toBe(file)
|
||||
expect(upload.isUploading.value).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the authorization header when there is no session token', async () => {
|
||||
getValidTokenMock.mockResolvedValue(null)
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent[0]?.headers).toEqual({})
|
||||
})
|
||||
|
||||
it('reports upload progress while the file is sending', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.progress(25, 100)
|
||||
|
||||
expect(upload.files.value[0]?.status).toBe('uploading')
|
||||
expect(upload.files.value[0]?.progress).toBe(25)
|
||||
})
|
||||
|
||||
it('drops a file from the queue once the server accepts it', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(201, { id: 7, fileName: 'dune.epub' })
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.files.value).toHaveLength(0)
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('uploads at most three files at a time and finishes the rest', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles(['a', 'b', 'c', 'd', 'e'].map((name) => book(`${name}.epub`)))
|
||||
await flushPromises()
|
||||
expect(FakeXhr.sent).toHaveLength(3)
|
||||
|
||||
for (const xhr of FakeXhr.sent) xhr.respond(201, {})
|
||||
await flushPromises()
|
||||
expect(FakeXhr.sent).toHaveLength(5)
|
||||
|
||||
for (const xhr of FakeXhr.sent.slice(3)) xhr.respond(201, {})
|
||||
await flushPromises()
|
||||
expect(upload.files.value).toHaveLength(0)
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps going with the rest of a batch after one upload fails', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub'), book('b.epub'), book('c.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(500, bookOrbitError(500, 'Internal server error'))
|
||||
FakeXhr.sent[1]?.respond(201, {})
|
||||
FakeXhr.sent[2]?.respond(201, {})
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value.map((f) => f.file.name)).toEqual(['a.epub'])
|
||||
expect(upload.files.value).toHaveLength(1)
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBookDockUpload server failures', () => {
|
||||
it('keeps the error code and message from a BookOrbit rejection', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(413, bookOrbitError(413, 'File exceeds the 1 MB upload limit', 'UPLOAD_TOO_LARGE'))
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toEqual({
|
||||
kind: 'server',
|
||||
status: 413,
|
||||
errorCode: 'UPLOAD_TOO_LARGE',
|
||||
message: 'File exceeds the 1 MB upload limit',
|
||||
fromBookOrbit: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the first message from a validation error list', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(400, { ...bookOrbitError(400, ''), message: ['file must be provided', 'second'] })
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toMatchObject({ kind: 'server', status: 400, message: 'file must be provided' })
|
||||
})
|
||||
|
||||
it('marks an HTML error page as coming from in front of BookOrbit', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(413, '<html><body><h1>413 Request Entity Too Large</h1></body></html>')
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'server', status: 413, errorCode: null, message: null, fromBookOrbit: false })
|
||||
})
|
||||
|
||||
it('does not trust a JSON body that lacks the BookOrbit error envelope', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(502, { error: 'bad gateway' })
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'server', status: 502, errorCode: null, message: null, fromBookOrbit: false })
|
||||
})
|
||||
|
||||
it('treats a blank message as no message', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('dune.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(500, bookOrbitError(500, ' '))
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toMatchObject({ message: null, fromBookOrbit: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBookDockUpload requests that never complete', () => {
|
||||
it('records a dropped connection as a network failure and keeps the queue moving', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.drop()
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'network' })
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
|
||||
upload.addFiles([book('b.epub')])
|
||||
await flushPromises()
|
||||
expect(FakeXhr.sent).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('fails the file instead of hanging when the session token cannot be refreshed', async () => {
|
||||
getValidTokenMock.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub')])
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(0)
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'network' })
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
|
||||
upload.addFiles([book('b.epub')])
|
||||
await flushPromises()
|
||||
expect(FakeXhr.sent).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails the file when the browser refuses to send the request', async () => {
|
||||
vi.spyOn(FakeXhr.prototype, 'send').mockImplementationOnce(() => {
|
||||
throw new DOMException('The object is in an invalid state.', 'InvalidStateError')
|
||||
})
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub')])
|
||||
await flushPromises()
|
||||
|
||||
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'network' })
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBookDockUpload retry and dismiss', () => {
|
||||
it('retries a transient failure and clears it once the upload succeeds', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(503, bookOrbitError(503, 'Service unavailable'))
|
||||
await flushPromises()
|
||||
const id = upload.failures.value[0]!.id
|
||||
|
||||
upload.retryFailure(id)
|
||||
await flushPromises()
|
||||
expect(FakeXhr.sent).toHaveLength(2)
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
|
||||
FakeXhr.sent[1]?.respond(201, {})
|
||||
await flushPromises()
|
||||
expect(upload.files.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('picks up a retry while another upload is still in flight', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.drop()
|
||||
await flushPromises()
|
||||
upload.addFiles([book('b.epub')])
|
||||
await flushPromises()
|
||||
|
||||
upload.retryFailure(upload.failures.value[0]!.id)
|
||||
FakeXhr.sent[1]?.respond(201, {})
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(3)
|
||||
expect(FakeXhr.sent[2]?.body?.get('file')).toMatchObject({ name: 'a.epub' })
|
||||
})
|
||||
|
||||
it('refuses to retry failures that would fail the same way again', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('notes.docx'), book('a.epub')])
|
||||
await flushPromises()
|
||||
FakeXhr.sent[0]?.respond(413, bookOrbitError(413, 'File exceeds the 1 MB upload limit', 'UPLOAD_TOO_LARGE'))
|
||||
await flushPromises()
|
||||
|
||||
for (const failure of upload.failures.value) upload.retryFailure(failure.id)
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(1)
|
||||
expect(upload.failures.value).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('ignores a retry for an unknown id', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.retryFailure('missing')
|
||||
await flushPromises()
|
||||
|
||||
expect(FakeXhr.sent).toHaveLength(0)
|
||||
expect(upload.isUploading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('dismisses one failure or all of them', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.docx'), book('b.docx'), book('c.docx')])
|
||||
upload.dismissFailure(upload.failures.value[0]!.id)
|
||||
expect(upload.failures.value.map((f) => f.file.name)).toEqual(['b.docx', 'c.docx'])
|
||||
|
||||
upload.dismissAllFailures()
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never dismisses an upload that is still in flight', async () => {
|
||||
const { upload } = await loadComposable()
|
||||
|
||||
upload.addFiles([book('a.epub'), book('b.docx')])
|
||||
await flushPromises()
|
||||
const inFlight = upload.files.value.find((f) => f.status === 'uploading')!
|
||||
|
||||
upload.dismissFailure(inFlight.id)
|
||||
upload.dismissAllFailures()
|
||||
|
||||
expect(upload.files.value.map((f) => f.id)).toEqual([inFlight.id])
|
||||
})
|
||||
|
||||
it('shares failures between every caller, such as the toolbar and the page', async () => {
|
||||
const { upload, useBookDockUpload } = await loadComposable()
|
||||
const other = useBookDockUpload()
|
||||
|
||||
upload.addFiles([book('notes.docx')])
|
||||
|
||||
expect(other.failures.value).toHaveLength(1)
|
||||
other.dismissAllFailures()
|
||||
expect(upload.failures.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getValidToken } from '@/lib/api'
|
||||
import type { BookDockFile } from '@bookorbit/types'
|
||||
|
||||
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||
import { isRetryableUploadFailure, type BookDockUploadFailure } from '../lib/upload-failure'
|
||||
|
||||
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(',')
|
||||
@@ -14,80 +15,114 @@ export interface UploadItem {
|
||||
file: File
|
||||
status: FileUploadStatus
|
||||
progress: number
|
||||
error?: string
|
||||
failure?: BookDockUploadFailure
|
||||
bookDockFile?: BookDockFile
|
||||
}
|
||||
|
||||
const CONCURRENCY = 3
|
||||
|
||||
function validateFile(file: File): string | null {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
if (!SUPPORTED_FORMATS.includes(ext)) return `Unsupported type .${ext}`
|
||||
function fileExtension(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
|
||||
}
|
||||
|
||||
function validateFile(file: File): BookDockUploadFailure | null {
|
||||
const extension = fileExtension(file.name)
|
||||
if (!SUPPORTED_FORMATS.includes(extension)) return { kind: 'unsupported', extension }
|
||||
const { maxUploadSizeMb } = useAppInfo()
|
||||
const limitBytes = maxUploadSizeMb.value * 1024 * 1024
|
||||
if (file.size > limitBytes) return `File exceeds ${maxUploadSizeMb.value} MB limit`
|
||||
if (file.size > limitBytes) return { kind: 'tooLarge', limitBytes }
|
||||
return null
|
||||
}
|
||||
|
||||
function responseFailure(xhr: XMLHttpRequest): BookDockUploadFailure {
|
||||
if (xhr.status === 0) return { kind: 'network' }
|
||||
let body: unknown = null
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText)
|
||||
} catch {
|
||||
// A proxy's HTML error page carries no structured reason.
|
||||
}
|
||||
const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : null
|
||||
const rawMessage = Array.isArray(record?.message) ? record.message[0] : record?.message
|
||||
const message = typeof rawMessage === 'string' && rawMessage.trim() ? rawMessage.trim() : null
|
||||
return {
|
||||
kind: 'server',
|
||||
status: xhr.status,
|
||||
errorCode: typeof record?.errorCode === 'string' ? record.errorCode : null,
|
||||
message,
|
||||
fromBookOrbit: typeof record?.statusCode === 'number' && typeof record?.requestId === 'string',
|
||||
}
|
||||
}
|
||||
|
||||
function fail(item: UploadItem, failure: BookDockUploadFailure) {
|
||||
item.status = 'error'
|
||||
item.failure = failure
|
||||
}
|
||||
|
||||
async function uploadSingle(item: UploadItem): Promise<void> {
|
||||
const token = await getValidToken()
|
||||
return new Promise((resolve) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', item.file)
|
||||
item.status = 'uploading'
|
||||
item.progress = 0
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/v1/book-dock/upload')
|
||||
let token: string | null
|
||||
try {
|
||||
token = await getValidToken()
|
||||
} catch {
|
||||
fail(item, { kind: 'network' })
|
||||
return
|
||||
}
|
||||
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('POST', '/api/v1/book-dock/upload')
|
||||
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.bookDockFile = JSON.parse(xhr.responseText) as BookDockFile
|
||||
} catch {
|
||||
// response parsing optional
|
||||
}
|
||||
} else {
|
||||
item.status = 'error'
|
||||
try {
|
||||
item.error = (JSON.parse(xhr.responseText) as { message?: string }).message ?? 'Upload failed'
|
||||
} catch {
|
||||
item.error = `Upload failed (${xhr.status})`
|
||||
}
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) item.progress = Math.round((e.loaded / e.total) * 100)
|
||||
}
|
||||
|
||||
// loadend follows load, error, abort and timeout alike, so a request that ends any other way
|
||||
// than load cannot leave the queue waiting forever.
|
||||
xhr.onloadend = () => {
|
||||
if (xhr.status === 201) {
|
||||
item.status = 'done'
|
||||
item.progress = 100
|
||||
try {
|
||||
item.bookDockFile = JSON.parse(xhr.responseText) as BookDockFile
|
||||
} catch {
|
||||
// response parsing optional
|
||||
}
|
||||
} else {
|
||||
fail(item, responseFailure(xhr))
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', item.file)
|
||||
xhr.send(formData)
|
||||
} catch {
|
||||
fail(item, { kind: 'network' })
|
||||
resolve()
|
||||
}
|
||||
|
||||
xhr.onerror = () => {
|
||||
item.status = 'error'
|
||||
item.error = 'Network error'
|
||||
resolve()
|
||||
}
|
||||
|
||||
item.status = 'uploading'
|
||||
xhr.send(formData)
|
||||
})
|
||||
}
|
||||
|
||||
const files = ref<UploadItem[]>([])
|
||||
const isUploading = ref(false)
|
||||
const failures = computed(() => files.value.filter((f) => f.status === 'error'))
|
||||
|
||||
export function useBookDockUpload() {
|
||||
function addFiles(fileList: FileList | File[]) {
|
||||
for (const file of fileList) {
|
||||
const error = validateFile(file)
|
||||
const failure = validateFile(file)
|
||||
const item: UploadItem = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
file,
|
||||
status: error ? 'error' : 'pending',
|
||||
status: failure ? 'error' : 'pending',
|
||||
progress: 0,
|
||||
error: error ?? undefined,
|
||||
failure: failure ?? undefined,
|
||||
}
|
||||
files.value.push(item)
|
||||
}
|
||||
@@ -96,26 +131,45 @@ export function useBookDockUpload() {
|
||||
|
||||
function processQueue() {
|
||||
if (isUploading.value) return
|
||||
if (!files.value.some((f) => f.status === 'pending')) return
|
||||
isUploading.value = true
|
||||
void drainQueue()
|
||||
}
|
||||
|
||||
async function drainQueue() {
|
||||
while (true) {
|
||||
const pending = files.value.filter((f) => f.status === 'pending').slice(0, CONCURRENCY)
|
||||
if (pending.length === 0) break
|
||||
await Promise.all(pending.map(uploadSingle))
|
||||
try {
|
||||
while (true) {
|
||||
const pending = files.value.filter((f) => f.status === 'pending').slice(0, CONCURRENCY)
|
||||
if (pending.length === 0) break
|
||||
await Promise.all(pending.map(uploadSingle))
|
||||
}
|
||||
} finally {
|
||||
// Nothing reads a finished upload, and holding its File keeps it alive for the session.
|
||||
files.value = files.value.filter((f) => f.status !== 'done')
|
||||
isUploading.value = false
|
||||
}
|
||||
isUploading.value = false
|
||||
}
|
||||
|
||||
function clearCompleted() {
|
||||
files.value = files.value.filter((f) => f.status !== 'done')
|
||||
function retryFailure(id: string) {
|
||||
const item = files.value.find((f) => f.id === id)
|
||||
if (!item || item.status !== 'error' || !item.failure || !isRetryableUploadFailure(item.failure)) return
|
||||
item.status = 'pending'
|
||||
item.failure = undefined
|
||||
item.progress = 0
|
||||
processQueue()
|
||||
}
|
||||
|
||||
function dismissFailure(id: string) {
|
||||
files.value = files.value.filter((f) => f.id !== id || f.status !== 'error')
|
||||
}
|
||||
|
||||
function dismissAllFailures() {
|
||||
files.value = files.value.filter((f) => f.status !== 'error')
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
files.value = []
|
||||
}
|
||||
|
||||
return { files, isUploading, addFiles, clearCompleted, clearAll }
|
||||
return { files, failures, isUploading, addFiles, retryFailure, dismissFailure, dismissAllFailures, clearAll }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment node
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { UploadErrorCode } from '@bookorbit/types'
|
||||
import { i18n } from '@/i18n'
|
||||
import en from '@/locales/en.json'
|
||||
import { describeUploadFailure, isRetryableUploadFailure, type BookDockUploadFailure, type UploadFailureReason } from '../upload-failure'
|
||||
|
||||
const MB = 1024 * 1024
|
||||
|
||||
function server(overrides: Partial<Extract<BookDockUploadFailure, { kind: 'server' }>> = {}): BookDockUploadFailure {
|
||||
return { kind: 'server', status: 500, errorCode: null, message: null, fromBookOrbit: true, ...overrides }
|
||||
}
|
||||
|
||||
function render(reason: UploadFailureReason): string {
|
||||
if ('text' in reason) return reason.text
|
||||
return i18n.global.t(reason.key, reason.params ?? {})
|
||||
}
|
||||
|
||||
function hasSourceMessage(key: string): boolean {
|
||||
let node: unknown = en
|
||||
for (const part of key.split('.')) {
|
||||
if (typeof node !== 'object' || node === null) return false
|
||||
node = (node as Record<string, unknown>)[part]
|
||||
}
|
||||
return typeof node === 'string'
|
||||
}
|
||||
|
||||
describe('describeUploadFailure', () => {
|
||||
it('names the rejected extension', () => {
|
||||
expect(render(describeUploadFailure({ kind: 'unsupported', extension: 'docx' }))).toBe('Unsupported file type: .docx')
|
||||
})
|
||||
|
||||
it('explains a file without any extension instead of printing a bare dot', () => {
|
||||
expect(render(describeUploadFailure({ kind: 'unsupported', extension: '' }))).toBe('The file has no extension, so its format is unknown')
|
||||
})
|
||||
|
||||
it('formats the client-side limit as a localized size', () => {
|
||||
expect(render(describeUploadFailure({ kind: 'tooLarge', limitBytes: 500 * MB }))).toBe('Larger than the 500 MB upload limit')
|
||||
expect(render(describeUploadFailure({ kind: 'tooLarge', limitBytes: 4096 * MB }))).toBe('Larger than the 4 GB upload limit')
|
||||
})
|
||||
|
||||
it('reports a network failure', () => {
|
||||
expect(render(describeUploadFailure({ kind: 'network' }))).toBe('Could not reach the server')
|
||||
})
|
||||
|
||||
it('translates the upload error codes the server sends', () => {
|
||||
expect(
|
||||
render(describeUploadFailure(server({ status: 413, errorCode: UploadErrorCode.TooLarge, message: 'File exceeds the 1 MB upload limit' }))),
|
||||
).toBe('Larger than the server upload limit')
|
||||
expect(render(describeUploadFailure(server({ status: 507, errorCode: UploadErrorCode.StorageFull, message: 'no space' })))).toBe(
|
||||
'The server is out of storage space',
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an uncoded 413 from BookOrbit as its own limit', () => {
|
||||
expect(render(describeUploadFailure(server({ status: 413, message: 'request file too large' })))).toBe('Larger than the server upload limit')
|
||||
})
|
||||
|
||||
it('blames a reverse proxy for a 413 that did not come from BookOrbit', () => {
|
||||
expect(render(describeUploadFailure(server({ status: 413, fromBookOrbit: false })))).toBe(
|
||||
'Rejected as too large before it reached BookOrbit, likely by a reverse proxy',
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the server message when no translated reason fits', () => {
|
||||
expect(describeUploadFailure(server({ status: 500, message: 'EACCES: permission denied' }))).toEqual({ text: 'EACCES: permission denied' })
|
||||
})
|
||||
|
||||
it('falls back to the status when the response carried no message', () => {
|
||||
expect(render(describeUploadFailure(server({ status: 502, fromBookOrbit: false })))).toBe('Upload failed (HTTP 502)')
|
||||
})
|
||||
|
||||
it('ignores an error code it has no translation for', () => {
|
||||
expect(describeUploadFailure(server({ status: 409, errorCode: 'SOMETHING_NEW', message: 'Already there' }))).toEqual({ text: 'Already there' })
|
||||
})
|
||||
|
||||
it('only returns keys that exist in the English source catalog', () => {
|
||||
const failures: BookDockUploadFailure[] = [
|
||||
{ kind: 'unsupported', extension: 'txt' },
|
||||
{ kind: 'unsupported', extension: '' },
|
||||
{ kind: 'tooLarge', limitBytes: MB },
|
||||
{ kind: 'network' },
|
||||
server({ status: 413, errorCode: UploadErrorCode.TooLarge }),
|
||||
server({ status: 507, errorCode: UploadErrorCode.StorageFull }),
|
||||
server({ status: 413, fromBookOrbit: false }),
|
||||
server({ status: 418 }),
|
||||
]
|
||||
const missing = failures.map(describeUploadFailure).filter((reason) => !('key' in reason) || !hasSourceMessage(reason.key))
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRetryableUploadFailure', () => {
|
||||
it.each<[string, BookDockUploadFailure, boolean]>([
|
||||
['network failure', { kind: 'network' }, true],
|
||||
['server error', server({ status: 500 }), true],
|
||||
['proxy gateway error', server({ status: 502, fromBookOrbit: false }), true],
|
||||
['request timeout', server({ status: 408 }), true],
|
||||
['rate limit', server({ status: 429 }), true],
|
||||
['expired session', server({ status: 401 }), true],
|
||||
['full disk', server({ status: 507, errorCode: UploadErrorCode.StorageFull }), true],
|
||||
['unsupported extension', { kind: 'unsupported', extension: 'docx' }, false],
|
||||
['over the client limit', { kind: 'tooLarge', limitBytes: MB }, false],
|
||||
['over the server limit', server({ status: 413, errorCode: UploadErrorCode.TooLarge }), false],
|
||||
['over a proxy limit', server({ status: 413, fromBookOrbit: false }), false],
|
||||
['bad request', server({ status: 400 }), false],
|
||||
['forbidden', server({ status: 403 }), false],
|
||||
['conflict', server({ status: 409 }), false],
|
||||
])('%s', (_label, failure, expected) => {
|
||||
expect(isRetryableUploadFailure(failure)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { UploadErrorCode } from '@bookorbit/types'
|
||||
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
|
||||
export type BookDockUploadFailure =
|
||||
| { kind: 'unsupported'; extension: string }
|
||||
| { kind: 'tooLarge'; limitBytes: number }
|
||||
| {
|
||||
kind: 'server'
|
||||
status: number
|
||||
errorCode: string | null
|
||||
message: string | null
|
||||
/** False when the error page came from something in front of BookOrbit, such as a reverse proxy. */
|
||||
fromBookOrbit: boolean
|
||||
}
|
||||
| { kind: 'network' }
|
||||
|
||||
/** A translation key, or server-authored text shown as-is when no translated reason fits. */
|
||||
export type UploadFailureReason = { key: string; params?: Record<string, string> } | { text: string }
|
||||
|
||||
const REASON_KEY_BY_ERROR_CODE: Partial<Record<string, string>> = {
|
||||
[UploadErrorCode.TooLarge]: 'bookDock.uploadFailures.reason.tooLargeServer',
|
||||
[UploadErrorCode.StorageFull]: 'bookDock.uploadFailures.reason.storageFull',
|
||||
}
|
||||
|
||||
const RETRYABLE_STATUSES = new Set([401, 408, 429])
|
||||
|
||||
/** A retry can only help when the failure was transient; a rejected file fails the same way again. */
|
||||
export function isRetryableUploadFailure(failure: BookDockUploadFailure): boolean {
|
||||
if (failure.kind === 'network') return true
|
||||
if (failure.kind !== 'server') return false
|
||||
return failure.status >= 500 || RETRYABLE_STATUSES.has(failure.status)
|
||||
}
|
||||
|
||||
export function describeUploadFailure(failure: BookDockUploadFailure): UploadFailureReason {
|
||||
switch (failure.kind) {
|
||||
case 'unsupported':
|
||||
return failure.extension
|
||||
? { key: 'bookDock.uploadFailures.reason.unsupported', params: { extension: failure.extension } }
|
||||
: { key: 'bookDock.uploadFailures.reason.noExtension' }
|
||||
case 'tooLarge':
|
||||
return { key: 'bookDock.uploadFailures.reason.tooLarge', params: { limit: formatBytes(failure.limitBytes) } }
|
||||
case 'network':
|
||||
return { key: 'bookDock.uploadFailures.reason.network' }
|
||||
case 'server': {
|
||||
const key = failure.errorCode ? REASON_KEY_BY_ERROR_CODE[failure.errorCode] : undefined
|
||||
if (key) return { key }
|
||||
if (failure.status === 413) {
|
||||
return { key: failure.fromBookOrbit ? 'bookDock.uploadFailures.reason.tooLargeServer' : 'bookDock.uploadFailures.reason.tooLargeProxy' }
|
||||
}
|
||||
if (failure.message) return { text: failure.message }
|
||||
return { key: 'bookDock.uploadFailures.reason.status', params: { status: String(failure.status) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7054,6 +7054,22 @@
|
||||
"nFailed": "{count} failed",
|
||||
"nTotal": "{count} total"
|
||||
},
|
||||
"uploadFailures": {
|
||||
"heading": "{count, plural, one {Could not upload # file} other {Could not upload # files}}",
|
||||
"dismissAll": "Dismiss all",
|
||||
"retry": "Retry uploading {name}",
|
||||
"dismiss": "Dismiss {name}",
|
||||
"reason": {
|
||||
"unsupported": "Unsupported file type: .{extension}",
|
||||
"noExtension": "The file has no extension, so its format is unknown",
|
||||
"tooLarge": "Larger than the {limit} upload limit",
|
||||
"tooLargeServer": "Larger than the server upload limit",
|
||||
"tooLargeProxy": "Rejected as too large before it reached BookOrbit, likely by a reverse proxy",
|
||||
"storageFull": "The server is out of storage space",
|
||||
"network": "Could not reach the server",
|
||||
"status": "Upload failed (HTTP {status})"
|
||||
}
|
||||
},
|
||||
"fileList": {
|
||||
"emptyTitle": "No files in Book Dock",
|
||||
"emptyMessageDefault": "Upload files or drop them in the Book Dock folder",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useBookDockUpload, SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT } from '
|
||||
import { useBookDockConflicts } from '@/features/book-dock/composables/useBookDockConflicts'
|
||||
import { refetchBookDockMetadata } from '@/features/book-dock/api/book-dock.api'
|
||||
import BookDockActionCluster from '@/features/book-dock/components/BookDockActionCluster.vue'
|
||||
import BookDockUploadFailures from '@/features/book-dock/components/BookDockUploadFailures.vue'
|
||||
import BookDockFilterBar from '@/features/book-dock/components/BookDockFilterBar.vue'
|
||||
import BookDockFileListView from '@/features/book-dock/components/BookDockFileListView.vue'
|
||||
import BookDockListBar from '@/features/book-dock/components/BookDockListBar.vue'
|
||||
@@ -435,16 +436,9 @@ function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragCounter = 0
|
||||
dragOver.value = false
|
||||
// Unsupported files go through too, so the upload failures panel can say why they were refused.
|
||||
const files = e.dataTransfer?.files
|
||||
if (files?.length) {
|
||||
const valid = [...files].filter((f) => {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase() ?? ''
|
||||
return SUPPORTED_FORMATS.includes(ext)
|
||||
})
|
||||
if (valid.length) {
|
||||
addFiles(valid)
|
||||
}
|
||||
}
|
||||
if (files?.length) addFiles(files)
|
||||
}
|
||||
|
||||
watch(isUploading, (uploading, was) => {
|
||||
@@ -571,6 +565,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookDockUploadFailures />
|
||||
|
||||
<BookDockFilterBar :active-view="activeView" :summary="summary" @view="setView" @search="setSearch" />
|
||||
|
||||
<!-- Statistics bar -->
|
||||
|
||||
@@ -27,8 +27,10 @@ vi.mock('@/features/book-dock/composables/useBookDockSummary', () => ({
|
||||
vi.mock('@/features/book-dock/composables/useBookDockStatistics', () => ({
|
||||
useBookDockStatistics: () => ({ statistics: ref(null), fetchStatistics: vi.fn<() => void>() }),
|
||||
}))
|
||||
const { addFilesMock } = vi.hoisted(() => ({ addFilesMock: vi.fn<(files: FileList | File[]) => void>() }))
|
||||
|
||||
vi.mock('@/features/book-dock/composables/useBookDockUpload', () => ({
|
||||
useBookDockUpload: () => ({ addFiles: vi.fn<() => void>(), isUploading: ref(false) }),
|
||||
useBookDockUpload: () => ({ addFiles: addFilesMock, isUploading: ref(false) }),
|
||||
SUPPORTED_FORMATS: ['epub'],
|
||||
SUPPORTED_FORMATS_ACCEPT: '.epub',
|
||||
}))
|
||||
@@ -56,3 +58,34 @@ describe('BookDockView pagination', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BookDockView uploads', () => {
|
||||
it('shows the upload failures panel on the page', () => {
|
||||
const wrapper = shallowMount(BookDockView)
|
||||
|
||||
expect(wrapper.findComponent({ name: 'BookDockUploadFailures' }).exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('hands every dropped file to the uploader, including ones it will refuse, so the refusal is reported', async () => {
|
||||
addFilesMock.mockClear()
|
||||
const wrapper = shallowMount(BookDockView)
|
||||
const files = [new File(['x'], 'dune.epub'), new File(['x'], 'notes.docx')]
|
||||
|
||||
await wrapper.get('main').trigger('drop', { dataTransfer: { files } })
|
||||
|
||||
expect(addFilesMock).toHaveBeenCalledTimes(1)
|
||||
expect([...addFilesMock.mock.calls[0]![0]].map((file) => file.name)).toEqual(['dune.epub', 'notes.docx'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores a drop that carries no files', async () => {
|
||||
addFilesMock.mockClear()
|
||||
const wrapper = shallowMount(BookDockView)
|
||||
|
||||
await wrapper.get('main').trigger('drop', { dataTransfer: { files: [] } })
|
||||
|
||||
expect(addFilesMock).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user