diff --git a/client/src/features/book-dock/components/BookDockActionCluster.vue b/client/src/features/book-dock/components/BookDockActionCluster.vue index e0ac970a6..0b4421b42 100644 --- a/client/src/features/book-dock/components/BookDockActionCluster.vue +++ b/client/src/features/book-dock/components/BookDockActionCluster.vue @@ -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(null) @@ -29,7 +29,6 @@ const processingStateChanging = ref(false) const canManageBookDock = computed(() => hasPermission(Permission.ManageBookDock)) function openFilePicker() { - clearCompleted() fileInput.value?.click() } diff --git a/client/src/features/book-dock/components/BookDockUploadFailures.vue b/client/src/features/book-dock/components/BookDockUploadFailures.vue new file mode 100644 index 000000000..1e46fe039 --- /dev/null +++ b/client/src/features/book-dock/components/BookDockUploadFailures.vue @@ -0,0 +1,82 @@ + + + diff --git a/client/src/features/book-dock/components/__tests__/BookDockUploadFailures.spec.ts b/client/src/features/book-dock/components/__tests__/BookDockUploadFailures.spec.ts new file mode 100644 index 000000000..d9984382c --- /dev/null +++ b/client/src/features/book-dock/components/__tests__/BookDockUploadFailures.spec.ts @@ -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>() })) + +vi.mock('@/lib/api', () => ({ + api: vi.fn<() => Promise>(), + 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) + }) +}) diff --git a/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts b/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts new file mode 100644 index 000000000..a6a146e8d --- /dev/null +++ b/client/src/features/book-dock/composables/__tests__/useBookDockUpload.spec.ts @@ -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>() })) + +vi.mock('@/lib/api', () => ({ + api: vi.fn<() => Promise>(), + getValidToken: getValidTokenMock, +})) + +const MB = 1024 * 1024 + +class FakeXhr { + static sent: FakeXhr[] = [] + method = '' + url = '' + headers: Record = {} + 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, '

413 Request Entity Too Large

') + 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) + }) +}) diff --git a/client/src/features/book-dock/composables/useBookDockUpload.ts b/client/src/features/book-dock/composables/useBookDockUpload.ts index 2b134d7d0..94ddb62f7 100644 --- a/client/src/features/book-dock/composables/useBookDockUpload.ts +++ b/client/src/features/book-dock/composables/useBookDockUpload.ts @@ -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) : 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 { - 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((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([]) 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 } } diff --git a/client/src/features/book-dock/lib/__tests__/upload-failure.spec.ts b/client/src/features/book-dock/lib/__tests__/upload-failure.spec.ts new file mode 100644 index 000000000..925268bf5 --- /dev/null +++ b/client/src/features/book-dock/lib/__tests__/upload-failure.spec.ts @@ -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> = {}): 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)[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) + }) +}) diff --git a/client/src/features/book-dock/lib/upload-failure.ts b/client/src/features/book-dock/lib/upload-failure.ts new file mode 100644 index 000000000..12fc4d474 --- /dev/null +++ b/client/src/features/book-dock/lib/upload-failure.ts @@ -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 } | { text: string } + +const REASON_KEY_BY_ERROR_CODE: Partial> = { + [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) } } + } + } +} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index f575a5d1d..db8579887 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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", diff --git a/client/src/views/BookDockView.vue b/client/src/views/BookDockView.vue index ab17bfc30..187c4b47b 100644 --- a/client/src/views/BookDockView.vue +++ b/client/src/views/BookDockView.vue @@ -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(() => { + + diff --git a/client/src/views/__tests__/BookDockView.spec.ts b/client/src/views/__tests__/BookDockView.spec.ts index e2038e2f6..1e8a5cf41 100644 --- a/client/src/views/__tests__/BookDockView.spec.ts +++ b/client/src/views/__tests__/BookDockView.spec.ts @@ -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() + }) +})