// The audio extractor reads FFPROBE_PATH as it loads, so this has to run ahead of every import below. vi.hoisted(() => { const requireFromHere = process.getBuiltinModule('node:module').createRequire(__filename); process.env.FFPROBE_PATH ??= (requireFromHere('@ffprobe-installer/ffprobe') as { path: string }).path; }); import { randomUUID } from 'crypto'; import { mkdir, readFile, realpath, utimes, writeFile } from 'fs/promises'; import { dirname, join } from 'path'; import { afterEach } from 'vitest'; import { eq, inArray } from 'drizzle-orm'; import { Permission, type AudiobookManifest, type AudiobookPlaybackState, type BookDockDiscardDuplicatesResult, type BookDockFile, type BookDockFinalizePreviewResult, type BookDockFinalizeResult, type DashboardScrollerResponse, } from '@bookorbit/types'; import * as schema from '../src/db/schema'; import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser'; import { ScannerService } from '../src/modules/scanner/scanner.service'; import { waitForCondition } from './e2e/app-harness'; import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder'; import { createM4bFixture, createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder'; import { authHeader, closeBookDockE2EContext, createLibraryWithFolder, createBookDockE2EContext, createBookDockRow, createUserAndLogin, fileExists, getBookDockRow, grantLibraryAccess, resetBookDockState, uploadBookDockFile, waitForBookDockStatus, type BookDockE2EContext, } from './e2e/book-dock/book-dock-harness'; interface ScenarioRunResult { id: string; status: 'passed' | 'failed'; durationMs: number; error?: string; } async function writeScenarioReport(results: ScenarioRunResult[]): Promise { const reportDir = process.env.JUNIT_OUTPUT ? dirname(process.env.JUNIT_OUTPUT) : join(process.cwd(), '..', 'test-results', 'server'); await mkdir(reportDir, { recursive: true }); const reportPath = join(reportDir, 'book-dock-ingest-finalize-e2e-scenarios.json'); await writeFile( reportPath, JSON.stringify( { generatedAt: new Date().toISOString(), total: results.length, passed: results.filter((result) => result.status === 'passed').length, failed: results.filter((result) => result.status === 'failed').length, results, }, null, 2, ), ); } describe('Book Dock ingest + finalize (e2e)', () => { let context!: BookDockE2EContext; const scenarioResults: ScenarioRunResult[] = []; let scenarioStartedAt = 0; beforeAll(async () => { context = await createBookDockE2EContext(); }); afterEach((taskContext) => { const result = taskContext.task.result; if (!result) return; const state = result.state === 'pass' ? 'passed' : 'failed'; const error = result.errors?.[0]?.message; scenarioResults.push({ id: taskContext.task.name, status: state, durationMs: Math.max(0, Date.now() - scenarioStartedAt), ...(error ? { error } : {}), }); }); afterAll(async () => { await writeScenarioReport(scenarioResults); if (context) { await closeBookDockE2EContext(context); } }); beforeEach(async () => { scenarioStartedAt = Date.now(); await resetBookDockState(context); }); it('ingests upload, extracts metadata, and supports review editing', async () => { const destination = await createLibraryWithFolder(context); const uploadResponse = await uploadBookDockFile(context, { token: context.adminToken, fileName: 'ingest-review.fb2', content: buildFb2Fixture({ title: 'Ingest Review Title', authors: ['Ingest Review Author'], description: 'Review flow', }), contentType: 'application/xml', }); expect(uploadResponse.statusCode).toBe(201); const uploaded = uploadResponse.json() as BookDockFile; const ready = await waitForBookDockStatus(context, uploaded.id, ['ready']); expect(ready.embeddedMetadata?.title).toBe('Ingest Review Title'); expect(ready.embeddedMetadata?.authors).toEqual(['Ingest Review Author']); expect(await fileExists(ready.absolutePath)).toBe(true); const patchResponse = await context.app.inject({ method: 'PATCH', url: `/api/v1/book-dock/files/${uploaded.id}`, headers: authHeader(context.adminToken), payload: { selectedMetadata: { title: 'Edited Review Title', authors: ['Edited Review Author'], genres: ['Mystery'], hardcoverId: 'edited-review-book', hardcoverEditionId: 'edited-review-edition', openLibraryId: 'OL123W', }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }, }); expect(patchResponse.statusCode).toBe(200); const updated = patchResponse.json() as BookDockFile; expect(updated.selectedMetadata).toMatchObject({ title: 'Edited Review Title', authors: ['Edited Review Author'], genres: ['Mystery'], hardcoverId: 'edited-review-book', hardcoverEditionId: 'edited-review-edition', openLibraryId: 'OL123W', }); expect(updated.targetLibraryId).toBe(destination.libraryId); expect(updated.targetFolderId).toBe(destination.libraryFolderId); const summaryResponse = await context.app.inject({ method: 'GET', url: '/api/v1/book-dock/summary', headers: authHeader(context.adminToken), }); expect(summaryResponse.statusCode).toBe(200); expect(summaryResponse.json()).toMatchObject({ total: 1, ready: 1 }); }); it('ingests pdf uploads and persists extracted cover files', async () => { const fixturePath = await createPdfFixture(context.fixture.rootPath, 'fixtures/book-dock-cover.pdf', 'Book Dock PDF Title'); const pdfBytes = await readFile(fixturePath); const uploadResponse = await uploadBookDockFile(context, { token: context.adminToken, fileName: 'book-dock-cover.pdf', content: pdfBytes, contentType: 'application/pdf', }); expect(uploadResponse.statusCode).toBe(201); const uploaded = uploadResponse.json() as BookDockFile; const ready = await waitForBookDockStatus(context, uploaded.id, ['ready']); expect(ready.embeddedMetadata?.title).toBe('Book Dock PDF Title'); expect(ready.coverPath).toEqual(expect.stringContaining('.jpg')); expect(ready.coverPath).not.toBeNull(); expect(await fileExists(ready.coverPath!)).toBe(true); }); it('bulk set-target selectAll with status=pending includes pending, extracting, and fetching', async () => { const destination = await createLibraryWithFolder(context); const pendingRow = await createBookDockRow(context, { fileName: 'pending-target.fb2', status: 'pending' }); const extractingRow = await createBookDockRow(context, { fileName: 'extracting-target.fb2', status: 'extracting' }); const fetchingRow = await createBookDockRow(context, { fileName: 'fetching-target.fb2', status: 'fetching' }); const readyRow = await createBookDockRow(context, { fileName: 'ready-target.fb2', status: 'ready' }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/set-target', headers: authHeader(context.adminToken), payload: { selectAll: true, excludedIds: [fetchingRow.id], status: 'pending', targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }, }); expect(response.statusCode).toBe(201); expect(response.json()).toEqual({ total: 2, updated: 2, failed: 0 }); const rows = await context.db .select({ id: schema.bookDockFiles.id, targetLibraryId: schema.bookDockFiles.targetLibraryId, targetFolderId: schema.bookDockFiles.targetFolderId, }) .from(schema.bookDockFiles) .where(inArray(schema.bookDockFiles.id, [pendingRow.id, extractingRow.id, fetchingRow.id, readyRow.id])); const rowById = new Map(rows.map((row) => [row.id, row])); expect(rowById.get(pendingRow.id)).toMatchObject({ targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); expect(rowById.get(extractingRow.id)).toMatchObject({ targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); expect(rowById.get(fetchingRow.id)).toMatchObject({ targetLibraryId: null, targetFolderId: null }); expect(rowById.get(readyRow.id)).toMatchObject({ targetLibraryId: null, targetFolderId: null }); }); it('bulk edit and apply fetched respect filters and metadataEditedAt', async () => { const editableA = await createBookDockRow(context, { fileName: 'bulk-edit-target-a.fb2', selectedMetadata: { authors: ['Existing Author'] }, fetchedMetadata: { title: 'Fetched A', authors: ['Fetched Author A'] }, }); const editableB = await createBookDockRow(context, { fileName: 'bulk-apply-edited-b.fb2', fetchedMetadata: { title: 'Fetched B' }, metadataEditedAt: new Date(), }); const untouched = await createBookDockRow(context, { fileName: 'untouched-record.fb2', selectedMetadata: { title: 'Untouched' }, fetchedMetadata: { title: 'Fetched Untouched' }, }); const bulkEditResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/bulk-edit', headers: authHeader(context.adminToken), payload: { selectAll: true, search: 'bulk-edit-target', fields: { title: 'Bulk Edited', authors: ['Merged Author'], }, enabledFields: ['title', 'authors'], mergeArrays: true, }, }); expect(bulkEditResponse.statusCode).toBe(201); expect(bulkEditResponse.json()).toEqual({ total: 1, updated: 1, failed: 0 }); const applyFetchedResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/apply-fetched', headers: authHeader(context.adminToken), payload: { fileIds: [editableA.id, editableB.id, untouched.id], }, }); expect(applyFetchedResponse.statusCode).toBe(201); expect(applyFetchedResponse.json()).toEqual({ total: 3, applied: 2, skipped: 0, skippedEdited: 1, }); const [rowA, rowB, rowUntouched] = await Promise.all([ getBookDockRow(context, editableA.id), getBookDockRow(context, editableB.id), getBookDockRow(context, untouched.id), ]); expect(rowA?.selectedMetadata).toMatchObject({ title: 'Fetched A', authors: ['Fetched Author A'] }); expect(rowB?.selectedMetadata ?? null).toBeNull(); expect(rowUntouched?.selectedMetadata).toMatchObject({ title: 'Fetched Untouched' }); }); it('bulk discard with selectAll + search honors excluded ids and removes files', async () => { const removable = await createBookDockRow(context, { fileName: 'discard-me-a.fb2' }); const excluded = await createBookDockRow(context, { fileName: 'discard-me-b.fb2' }); const untouched = await createBookDockRow(context, { fileName: 'keep-me.fb2' }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/discard', headers: authHeader(context.adminToken), payload: { selectAll: true, search: 'discard-me', excludedIds: [excluded.id], }, }); expect(response.statusCode).toBe(204); expect(await getBookDockRow(context, removable.id)).toBeUndefined(); expect(await getBookDockRow(context, excluded.id)).toBeDefined(); expect(await getBookDockRow(context, untouched.id)).toBeDefined(); expect(await fileExists(removable.absolutePath)).toBe(false); expect(await fileExists(excluded.absolutePath)).toBe(true); expect(await fileExists(untouched.absolutePath)).toBe(true); }); it.each(['finalize', 'discard'] as const)('rescans nested shared-folder books and %ss only the selected book', async (action) => { const destination = await createLibraryWithFolder(context); const dock = await realpath(context.fixture.bookDockPath); const directory = join(dock, 'ebooks', 'Author', 'Title'); await mkdir(directory, { recursive: true }); const firstPath = join(directory, 'first.fb2'); const old = new Date(Date.now() - 120_000); await writeFile(firstPath, buildFb2Fixture({ title: 'First Nested Book' })); await utimes(firstPath, old, old); const rescan = () => context.app.inject({ method: 'POST', url: '/api/v1/book-dock/rescan', headers: authHeader(context.adminToken) }); expect((await rescan()).statusCode).toBe(204); const [first] = await context.db.select().from(schema.bookDockFiles).where(eq(schema.bookDockFiles.absolutePath, firstPath)); expect(first?.unitDirectory).toBe(directory); await waitForBookDockStatus(context, first.id, ['ready']); const secondPath = join(directory, 'second.fb2'); await writeFile(secondPath, buildFb2Fixture({ title: 'Second Nested Book' })); const pdfPath = await createPdfFixture(directory, 'second.pdf', 'Second Nested Book'); await utimes(secondPath, old, old); await utimes(pdfPath, old, old); expect((await rescan()).statusCode).toBe(204); expect((await rescan()).statusCode).toBe(204); const dockRows = await context.db.select().from(schema.bookDockFiles); expect(dockRows).toHaveLength(2); const second = dockRows.find((row) => row.absolutePath === secondPath)!; expect(second.unitDirectory).toBeNull(); await waitForBookDockStatus(context, second.id, ['ready']); const detail = await context.app.inject({ method: 'GET', url: `/api/v1/book-dock/files/${second.id}`, headers: authHeader(context.adminToken) }); expect(detail.statusCode).toBe(200); expect((detail.json() as BookDockFile).unitFiles.map((file) => file.fileName).sort()).toEqual(['second.fb2', 'second.pdf']); if (action === 'finalize') { const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [second.id], defaultLibraryId: destination.libraryId, defaultFolderId: destination.libraryFolderId }, }); expect(response.statusCode).toBe(201); const result = response.json() as BookDockFinalizeResult; expect(result).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); const files = await context.db.select().from(schema.bookFiles).where(eq(schema.bookFiles.bookId, result.results[0].bookId!)); expect(files.map((file) => file.format).sort()).toEqual(['fb2', 'pdf']); expect(new Set(files.map((file) => dirname(file.absolutePath))).size).toBe(1); for (const file of files) expect(await fileExists(file.absolutePath)).toBe(true); } else { const response = await context.app.inject({ method: 'DELETE', url: `/api/v1/book-dock/files/${second.id}`, headers: authHeader(context.adminToken), }); expect(response.statusCode).toBe(204); } expect(await fileExists(firstPath)).toBe(true); expect(await getBookDockRow(context, first.id)).toBeDefined(); expect(await fileExists(secondPath)).toBe(false); expect(await fileExists(pdfPath)).toBe(false); expect(await getBookDockRow(context, second.id)).toBeUndefined(); }); it('finalize moves files into destination and creates book records', async () => { const destination = await createLibraryWithFolder(context); const bookDockRow = await createBookDockRow(context, { fileName: 'finalize-success.fb2', selectedMetadata: { title: 'Finalize Success Title', authors: ['Finalize Author'], hardcoverId: 'finalize-success-book', hardcoverEditionId: 'finalize-success-edition', openLibraryId: 'OL456W', }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [bookDockRow.id], }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); expect(body.results[0]?.bookId).toEqual(expect.any(Number)); const finalizedBookId = body.results[0]!.bookId!; const [book] = await context.db .select({ id: schema.books.id, libraryId: schema.books.libraryId, folderPath: schema.books.folderPath, }) .from(schema.books) .where(eq(schema.books.id, finalizedBookId)) .limit(1); expect(book).toMatchObject({ id: finalizedBookId, libraryId: destination.libraryId }); const [bookFile] = await context.db .select({ absolutePath: schema.bookFiles.absolutePath, relPath: schema.bookFiles.relPath, }) .from(schema.bookFiles) .where(eq(schema.bookFiles.bookId, finalizedBookId)) .limit(1); expect(bookFile).toBeDefined(); expect(bookFile!.absolutePath.startsWith(destination.folderPath)).toBe(true); expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath)); expect(await fileExists(bookFile!.absolutePath)).toBe(true); expect(await fileExists(bookDockRow.absolutePath)).toBe(false); const [metadata] = await context.db .select({ title: schema.bookMetadata.title, hardcoverId: schema.bookMetadata.hardcoverId, hardcoverEditionId: schema.bookMetadata.hardcoverEditionId, openLibraryId: schema.bookMetadata.openLibraryId, }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, finalizedBookId)) .limit(1); expect(metadata).toMatchObject({ title: 'Finalize Success Title', hardcoverId: 'finalize-success-book', hardcoverEditionId: 'finalize-success-edition', openLibraryId: 'OL456W', }); expect(await getBookDockRow(context, bookDockRow.id)).toBeUndefined(); }); /** * An ebook and its audiobook finalized one after the other land in one folder, so the second joins * the book the first created. The book has to open with whichever the library ranks first, not * with whichever arrived first. */ describe('when a second format joins a finalized book', () => { const metadata = { title: 'Two Media Title', authors: ['Two Media Author'] }; async function libraryRanking(formatPriority: string[] | null) { const destination = await createLibraryWithFolder(context, { allowedFormats: ['epub', 'm4b'] }); if (formatPriority) { await context.db.update(schema.libraries).set({ formatPriority }).where(eq(schema.libraries.id, destination.libraryId)); } return destination; } async function finalizeInto(destination: Awaited>, fileName: string): Promise { const row = await createBookDockRow(context, { fileName, selectedMetadata: metadata, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [row.id] }, }); expect(response.statusCode).toBe(201); const result = (response.json() as BookDockFinalizeResult).results[0]; expect(result).toMatchObject({ success: true, bookId: expect.any(Number) }); return result!.bookId!; } async function primaryFormatOf(bookId: number) { const files = await context.db .select({ id: schema.bookFiles.id, format: schema.bookFiles.format }) .from(schema.bookFiles) .where(eq(schema.bookFiles.bookId, bookId)); const [book] = await context.db .select({ primaryFileId: schema.books.primaryFileId }) .from(schema.books) .where(eq(schema.books.id, bookId)) .limit(1); return { formats: files.map((file) => file.format).sort(), primary: files.find((file) => file.id === book?.primaryFileId)?.format }; } it('makes the audiobook primary when it joins an ebook in a library that ranks audio first', async () => { const destination = await libraryRanking(['m4b', 'epub']); const bookId = await finalizeInto(destination, 'two-media-audio-first.epub'); expect(await primaryFormatOf(bookId)).toEqual({ formats: ['epub'], primary: 'epub' }); expect(await finalizeInto(destination, 'two-media-audio-first.m4b')).toBe(bookId); expect(await primaryFormatOf(bookId)).toEqual({ formats: ['epub', 'm4b'], primary: 'm4b' }); }); it('keeps the audiobook primary when an ebook joins it in a library that ranks audio first', async () => { const destination = await libraryRanking(['m4b', 'epub']); const bookId = await finalizeInto(destination, 'two-media-audio-kept.m4b'); expect(await finalizeInto(destination, 'two-media-audio-kept.epub')).toBe(bookId); expect(await primaryFormatOf(bookId)).toEqual({ formats: ['epub', 'm4b'], primary: 'm4b' }); }); it('keeps the ebook primary when an audiobook joins it under the default ranking', async () => { const destination = await libraryRanking(null); const bookId = await finalizeInto(destination, 'two-media-default.epub'); expect(await finalizeInto(destination, 'two-media-default.m4b')).toBe(bookId); expect(await primaryFormatOf(bookId)).toEqual({ formats: ['epub', 'm4b'], primary: 'epub' }); }); }); /** * Issue 1757: finalize filed audiobooks with no per-file duration, and the player derives progress * only from those, so listening stayed at 0% and the book never reached Continue Listening until * the library was next scanned. These libraries are not watched, so nothing else measures them. */ describe('listening to a filed audiobook', () => { const AUDIO_SECONDS = 4; const metadata = { title: 'Listening Title', authors: ['Listening Author'] }; async function finalizeRow(row: { id: number }): Promise { const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [row.id] }, }); expect(response.statusCode).toBe(201); const result = (response.json() as BookDockFinalizeResult).results[0]; expect(result).toMatchObject({ success: true, bookId: expect.any(Number) }); return result!.bookId!; } async function dockAudiobook(destination: Awaited>, fileName: string, selected = {}) { const fixture = await createM4bFixture(context.fixture.booksPath, `audio-fixtures/${randomUUID()}.m4b`, { title: metadata.title, durationSeconds: AUDIO_SECONDS, }); return createBookDockRow(context, { fileName, content: await readFile(fixture), selectedMetadata: { ...metadata, ...selected }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); } async function audioDurations(bookId: number) { const files = await context.db .select({ format: schema.bookFiles.format, durationSeconds: schema.bookFiles.durationSeconds }) .from(schema.bookFiles) .where(eq(schema.bookFiles.bookId, bookId)); const [meta] = await context.db .select({ durationSeconds: schema.bookMetadata.durationSeconds }) .from(schema.bookMetadata) .where(eq(schema.bookMetadata.bookId, bookId)) .limit(1); return { files: files.filter((file) => file.format === 'm4b').map((file) => file.durationSeconds), book: meta?.durationSeconds ?? null }; } /** What the web player and the iOS app do: read the manifest, then save a position halfway in. */ async function listenHalfway(bookId: number): Promise { const manifestResponse = await context.app.inject({ method: 'GET', url: `/api/v1/audiobooks/${bookId}/manifest`, headers: authHeader(context.adminToken), }); expect(manifestResponse.statusCode).toBe(200); const manifest = manifestResponse.json() as AudiobookManifest; expect(manifest.assets).toHaveLength(1); expect(manifest.assets[0]!.durationMs).toBe(AUDIO_SECONDS * 1000); expect(manifest.totalDurationMs).toBe(AUDIO_SECONDS * 1000); const saveResponse = await context.app.inject({ method: 'PUT', url: `/api/v1/audiobooks/${bookId}/playback-state`, headers: authHeader(context.adminToken), payload: { assetId: manifest.assets[0]!.assetId, positionMs: (AUDIO_SECONDS * 1000) / 2, capturedAt: new Date().toISOString(), operationId: randomUUID(), baseRevision: 0, manifestRevision: manifest.revision, }, }); expect(saveResponse.statusCode).toBe(200); return saveResponse.json() as AudiobookPlaybackState; } async function continueListeningIds(): Promise { const response = await context.app.inject({ method: 'GET', url: '/api/v1/dashboard/scrollers/continue-listening?limit=50', headers: authHeader(context.adminToken), }); expect(response.statusCode).toBe(200); return (response.json() as DashboardScrollerResponse).books.map((book) => book.id); } it('measures a new audiobook, so listening moves its progress and puts it on Continue Listening', async () => { const destination = await createLibraryWithFolder(context, { allowedFormats: ['m4b'] }); // A runtime from a metadata provider: the file's own length replaces it, as a scan would. const row = await dockAudiobook(destination, 'listening-new.m4b', { durationSeconds: 3600 }); const bookId = await finalizeRow(row); expect(await audioDurations(bookId)).toEqual({ files: [AUDIO_SECONDS], book: AUDIO_SECONDS }); expect(await listenHalfway(bookId)).toMatchObject({ percentage: 50, completed: false }); expect(await continueListeningIds()).toContain(bookId); }); it('measures an audiobook that joins an ebook already on the shelf', async () => { const destination = await createLibraryWithFolder(context, { allowedFormats: ['epub', 'm4b'] }); const ebook = await createBookDockRow(context, { fileName: 'listening-joined.epub', selectedMetadata: metadata, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const bookId = await finalizeRow(ebook); expect(await finalizeRow(await dockAudiobook(destination, 'listening-joined.m4b'))).toBe(bookId); expect(await audioDurations(bookId)).toEqual({ files: [AUDIO_SECONDS], book: AUDIO_SECONDS }); expect(await listenHalfway(bookId)).toMatchObject({ percentage: 50, completed: false }); expect(await continueListeningIds()).toContain(bookId); }); }); it('finalize writes the dock metadata into the file when the library writes metadata to files', async () => { const destination = await createLibraryWithFolder(context, { fileWriteEnabled: true, fileWriteFb2Enabled: true, }); const uploader = await createUserAndLogin(context, { permissions: [Permission.ManageBookDock] }); const bookDockRow = await createBookDockRow(context, { fileName: 'write-back.fb2', content: buildFb2Fixture({ title: 'Uploaded File Title', authors: ['Uploaded File Author'] }), embeddedMetadata: { title: 'Uploaded File Title', authors: ['Uploaded File Author'] }, selectedMetadata: { title: 'Dock Edited Title', authors: ['Dock Edited Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, uploadedBy: uploader.userId, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [bookDockRow.id] }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); const finalizedBookId = body.results[0]!.bookId!; const [bookFile] = await context.db .select({ absolutePath: schema.bookFiles.absolutePath }) .from(schema.bookFiles) .where(eq(schema.bookFiles.bookId, finalizedBookId)) .limit(1); expect(bookFile).toBeDefined(); // The write is debounced, so the file is polled rather than drained: draining cancels a timer // that has not fired yet. await waitForCondition(async () => { const parsed = await parseFb2File(bookFile!.absolutePath); expect(parsed?.title).toBe('Dock Edited Title'); expect(parsed?.authors.map((author) => author.name)).toEqual(['Dock Edited Author']); const [writeLog] = await context.db .select({ status: schema.fileWriteLog.status, triggeredBy: schema.fileWriteLog.triggeredBy, userId: schema.fileWriteLog.userId }) .from(schema.fileWriteLog) .where(eq(schema.fileWriteLog.bookId, finalizedBookId)) .limit(1); expect(writeLog).toMatchObject({ status: 'success', triggeredBy: 'auto', userId: uploader.userId }); }); }); it('finalize returns partial success with duplicate and destination conflicts', async () => { const destination = await createLibraryWithFolder(context); const seedRow = await createBookDockRow(context, { fileName: 'seed-duplicate.fb2', selectedMetadata: { title: 'Duplicate Seed Title', authors: ['Duplicate Author'], isbn13: '9780306406157' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const seedResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [seedRow.id] }, }); const seedBody = seedResponse.json() as BookDockFinalizeResult; const existingBookId = seedBody.results[0]!.bookId!; const duplicateRow = await createBookDockRow(context, { fileName: 'seed-duplicate.fb2', selectedMetadata: { title: 'Duplicate Seed Title', authors: ['Duplicate Author'], isbn13: '9780306406157' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const conflictRow = await createBookDockRow(context, { fileName: 'name-conflict.fb2', selectedMetadata: { title: 'Conflict Title', authors: ['Conflict Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const successRow = await createBookDockRow(context, { fileName: 'will-succeed.fb2', selectedMetadata: { title: 'Success Title', authors: ['Success Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const previewNamesResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/preview-names', headers: authHeader(context.adminToken), payload: { fileIds: [conflictRow.id], defaultLibraryId: destination.libraryId, }, }); expect(previewNamesResponse.statusCode).toBe(201); const previewRows = previewNamesResponse.json() as Array<{ fileId: number; newName: string }>; const conflictNewName = previewRows.find((row) => row.fileId === conflictRow.id)?.newName; expect(conflictNewName).toEqual(expect.any(String)); const existingDestinationPath = join(destination.folderPath, conflictNewName!); await mkdir(dirname(existingDestinationPath), { recursive: true }); await writeFile(existingDestinationPath, 'already exists'); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [duplicateRow.id, conflictRow.id, successRow.id], }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 3, succeeded: 1, failed: 2 }); const duplicateResult = body.results.find((result) => result.fileId === duplicateRow.id); const conflictResult = body.results.find((result) => result.fileId === conflictRow.id); const successResult = body.results.find((result) => result.fileId === successRow.id); expect(duplicateResult).toMatchObject({ success: false, isDuplicate: true, existingBookId, }); expect(conflictResult?.success).toBe(false); expect(conflictResult?.message).toContain('already exists at the target location'); expect(successResult?.success).toBe(true); expect(successResult?.bookId).toEqual(expect.any(Number)); expect(await getBookDockRow(context, duplicateRow.id)).toBeDefined(); expect(await getBookDockRow(context, conflictRow.id)).toBeDefined(); expect(await getBookDockRow(context, successRow.id)).toBeUndefined(); }); it('finalize preview reports duplicates and discard removes only duplicate candidates', async () => { const destination = await createLibraryWithFolder(context); const seedRow = await createBookDockRow(context, { fileName: 'preview-seed-duplicate.fb2', selectedMetadata: { title: 'Preview Duplicate Seed', authors: ['Preview Author'], isbn13: '9780306406157' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const seedResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [seedRow.id] }, }); const seedBody = seedResponse.json() as BookDockFinalizeResult; const existingBookId = seedBody.results[0]!.bookId!; const duplicateRow = await createBookDockRow(context, { fileName: 'preview-seed-duplicate.fb2', selectedMetadata: { title: 'Preview Duplicate Seed', authors: ['Preview Author'], isbn13: '9780306406157' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const conflictRow = await createBookDockRow(context, { fileName: 'preview-name-conflict.fb2', selectedMetadata: { title: 'Preview Conflict Title', authors: ['Preview Conflict Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const readyRow = await createBookDockRow(context, { fileName: 'preview-ready.fb2', selectedMetadata: { title: 'Preview Ready Title', authors: ['Preview Ready Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const previewNamesResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/preview-names', headers: authHeader(context.adminToken), payload: { fileIds: [conflictRow.id], defaultLibraryId: destination.libraryId, }, }); const previewRows = previewNamesResponse.json() as Array<{ fileId: number; newName: string }>; const conflictNewName = previewRows.find((row) => row.fileId === conflictRow.id)?.newName; expect(conflictNewName).toEqual(expect.any(String)); const existingDestinationPath = join(destination.folderPath, conflictNewName!); await mkdir(dirname(existingDestinationPath), { recursive: true }); await writeFile(existingDestinationPath, 'already exists'); const previewResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize/preview', headers: authHeader(context.adminToken), payload: { fileIds: [duplicateRow.id, conflictRow.id, readyRow.id], }, }); expect(previewResponse.statusCode).toBe(200); const preview = previewResponse.json() as BookDockFinalizePreviewResult; expect(preview).toMatchObject({ total: 3, ready: 1, duplicates: 1, destinationConflicts: 1, missingDestination: 0, blocked: 0, }); expect(preview.items).toEqual( expect.arrayContaining([ expect.objectContaining({ fileId: duplicateRow.id, status: 'duplicate', existingBookId }), expect.objectContaining({ fileId: conflictRow.id, status: 'destination_conflict' }), expect.objectContaining({ fileId: readyRow.id, status: 'ready' }), ]), ); const discardResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize/discard-duplicates', headers: authHeader(context.adminToken), payload: { fileIds: [duplicateRow.id, conflictRow.id, readyRow.id], }, }); expect(discardResponse.statusCode).toBe(200); const discard = discardResponse.json() as BookDockDiscardDuplicatesResult; expect(discard).toMatchObject({ total: 3, discarded: 1, skipped: 2, discardedFileIds: [duplicateRow.id] }); expect(await getBookDockRow(context, duplicateRow.id)).toBeUndefined(); expect(await getBookDockRow(context, conflictRow.id)).toBeDefined(); expect(await getBookDockRow(context, readyRow.id)).toBeDefined(); expect(await fileExists(duplicateRow.absolutePath)).toBe(false); expect(await fileExists(conflictRow.absolutePath)).toBe(true); expect(await fileExists(readyRow.absolutePath)).toBe(true); }); it('finalize allows the same ISBN when resolved destinations differ', async () => { const destination = await createLibraryWithFolder(context); const seedRow = await createBookDockRow(context, { fileName: 'same-isbn-first.fb2', selectedMetadata: { title: 'Same ISBN First', authors: ['Author One'], isbn13: '9780306406157' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const seedResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [seedRow.id] }, }); expect(seedResponse.statusCode).toBe(201); const seedBody = seedResponse.json() as BookDockFinalizeResult; expect(seedBody.results[0]?.success).toBe(true); const existingBookId = seedBody.results[0]!.bookId!; const candidateRow = await createBookDockRow(context, { fileName: 'same-isbn-second.fb2', selectedMetadata: { title: 'Same ISBN Second', authors: ['Author Two'], isbn13: '9780306406157' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [candidateRow.id] }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); expect(body.results[0]).toMatchObject({ fileId: candidateRow.id, success: true }); expect(body.results[0]?.isDuplicate).toBeUndefined(); expect(body.results[0]?.bookId).toEqual(expect.any(Number)); expect(body.results[0]?.bookId).not.toBe(existingBookId); expect(await getBookDockRow(context, candidateRow.id)).toBeUndefined(); }); it.each([ { mode: 'book_per_folder' as const, fileWriteEnabled: false }, { mode: 'book_per_folder' as const, fileWriteEnabled: true }, { mode: 'book_per_file' as const, fileWriteEnabled: false }, { mode: 'book_per_file' as const, fileWriteEnabled: true }, ])('preserves separate root books and metadata through repeated scans: $mode, writeBack=$fileWriteEnabled', async ({ mode, fileWriteEnabled }) => { const destination = await createLibraryWithFolder(context, { mode, fileWriteEnabled, fileWriteFb2Enabled: fileWriteEnabled }); const reader = await createUserAndLogin(context); await context.db .update(schema.libraries) .set({ fileNamingPattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>' }) .where(eq(schema.libraries.id, destination.libraryId)); const bookIds: number[] = []; const paths: string[] = []; for (const label of ['First', 'Second']) { const selectedMetadata = { title: `${label} Edited Title`, authors: [`${label} Edited Author`], publishedYear: 2024 }; const row = await createBookDockRow(context, { fileName: `${label.toLowerCase()}-root.fb2`, content: buildFb2Fixture({ title: `${label} Embedded Title`, authors: [`${label} Embedded Author`] }), selectedMetadata, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [row.id] }, }); expect(response.statusCode).toBe(201); const result = response.json() as BookDockFinalizeResult; expect(result).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); const bookId = result.results[0]!.bookId!; expect(bookIds).not.toContain(bookId); bookIds.push(bookId); const path = join(destination.folderPath, `${selectedMetadata.title} - ${selectedMetadata.authors[0]} - 2024.fb2`); paths.push(path); expect(await fileExists(path)).toBe(true); expect(await getBookDockRow(context, row.id)).toBeUndefined(); if (fileWriteEnabled) { await waitForCondition(async () => { expect((await parseFb2File(path))?.title).toBe(selectedMetadata.title); const [log] = await context.db .select({ status: schema.fileWriteLog.status }) .from(schema.fileWriteLog) .where(eq(schema.fileWriteLog.bookId, bookId)) .limit(1); expect(log?.status).toBe('success'); }); } else { expect((await parseFb2File(path))?.title).toBe(`${label} Embedded Title`); } if (label === 'First') { await context.db.insert(schema.userBookStatus).values({ userId: reader.userId, bookId, status: 'reading', source: 'manual' }); } } const readBooks = () => context.db .select({ id: schema.books.id, status: schema.books.status, folderPath: schema.books.folderPath, title: schema.bookMetadata.title, primaryFileId: schema.books.primaryFileId, }) .from(schema.books) .innerJoin(schema.bookMetadata, eq(schema.bookMetadata.bookId, schema.books.id)) .where(eq(schema.books.libraryId, destination.libraryId)) .orderBy(schema.books.id); const beforeScan = await readBooks(); expect(beforeScan).toHaveLength(2); expect(beforeScan.map((book) => book.id)).toEqual(bookIds); expect(beforeScan.map((book) => book.title)).toEqual(['First Edited Title', 'Second Edited Title']); expect(beforeScan.map((book) => book.folderPath)).toEqual(paths); const readFiles = () => context.db .select({ bookId: schema.bookFiles.bookId, publicId: schema.bookFiles.publicId, path: schema.bookFiles.absolutePath }) .from(schema.bookFiles) .where(inArray(schema.bookFiles.bookId, bookIds)) .orderBy(schema.bookFiles.bookId); const beforeFiles = await readFiles(); expect(beforeFiles.map(({ bookId, path }) => ({ bookId, path }))).toEqual(bookIds.map((bookId, index) => ({ bookId, path: paths[index] }))); for (let scan = 0; scan < 2; scan++) { const { jobId } = await context.app.get(ScannerService).startScan(destination.libraryId, 'manual', true); await waitForCondition(async () => { const [job] = await context.db.select().from(schema.scanJobs).where(eq(schema.scanJobs.id, jobId)).limit(1); expect(job).toMatchObject({ status: 'completed', addedCount: 0, missingCount: 0 }); }); expect(await readBooks()).toEqual(beforeScan); expect(await readFiles()).toEqual(beforeFiles); const statuses = await context.db .select({ bookId: schema.userBookStatus.bookId, status: schema.userBookStatus.status, source: schema.userBookStatus.source }) .from(schema.userBookStatus) .where(inArray(schema.userBookStatus.bookId, bookIds)); expect(statuses).toEqual([{ bookId: bookIds[0], status: 'reading', source: 'manual' }]); } }); it('finalize files a book_per_file library under the shipped default pattern folders', async () => { const destination = await createLibraryWithFolder(context, { mode: 'book_per_file' }); const bookDockRow = await createBookDockRow(context, { fileName: 'per-file-default-pattern.fb2', selectedMetadata: { title: 'Caliban Cove', authors: ['S.D. Perry'], seriesName: 'Resident Evil', seriesIndex: '2', publishedYear: 2012, }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const previewResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/preview-names', headers: authHeader(context.adminToken), payload: { fileIds: [bookDockRow.id] }, }); expect(previewResponse.statusCode).toBe(201); const preview = previewResponse.json() as Array<{ fileId: number; newName: string }>; expect(preview[0]?.newName).toBe('S.D. Perry/Resident Evil/02. Caliban Cove (2012).fb2'); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [bookDockRow.id] }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); expect(body.results[0]?.newName).toBe(preview[0]?.newName); const finalizedBookId = body.results[0]!.bookId!; const [book] = await context.db .select({ folderPath: schema.books.folderPath }) .from(schema.books) .where(eq(schema.books.id, finalizedBookId)) .limit(1); const [bookFile] = await context.db .select({ absolutePath: schema.bookFiles.absolutePath, relPath: schema.bookFiles.relPath }) .from(schema.bookFiles) .where(eq(schema.bookFiles.bookId, finalizedBookId)) .limit(1); const expectedPath = join(destination.folderPath, 'S.D. Perry', 'Resident Evil', '02. Caliban Cove (2012).fb2'); expect(bookFile?.absolutePath).toBe(expectedPath); expect(bookFile?.relPath).toBe('S.D. Perry/Resident Evil/02. Caliban Cove (2012).fb2'); expect(await fileExists(expectedPath)).toBe(true); expect(await fileExists(join(destination.folderPath, '02. Caliban Cove (2012).fb2'))).toBe(false); expect(book?.folderPath).toBe(expectedPath); }); it('finalize treats title duplicates as exact matches instead of wildcard patterns', async () => { const destination = await createLibraryWithFolder(context); const seedRow = await createBookDockRow(context, { fileName: 'seed-wildcard-duplicate.fb2', selectedMetadata: { title: 'The Real Title', authors: ['Seed Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const seedResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [seedRow.id] }, }); expect(seedResponse.statusCode).toBe(201); expect((seedResponse.json() as BookDockFinalizeResult).results[0]?.success).toBe(true); const wildcardTitleRow = await createBookDockRow(context, { fileName: 'wildcard-title.fb2', selectedMetadata: { title: 'The %', authors: ['Wildcard Author'] }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [wildcardTitleRow.id] }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); expect(body.results[0]).toMatchObject({ fileId: wildcardTitleRow.id, success: true }); expect(body.results[0]?.isDuplicate).toBeUndefined(); }); it('finalize selectAll honors status/search filters and excluded ids', async () => { const destination = await createLibraryWithFolder(context); const selected = await createBookDockRow(context, { fileName: 'batch-finalize-target-1.fb2', selectedMetadata: { title: 'Batch Finalize 1' }, status: 'ready', }); const excluded = await createBookDockRow(context, { fileName: 'batch-finalize-target-2.fb2', selectedMetadata: { title: 'Batch Finalize 2' }, status: 'ready', }); const unselected = await createBookDockRow(context, { fileName: 'other-finalize-target.fb2', selectedMetadata: { title: 'Other Finalize Target' }, status: 'ready', }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { selectAll: true, status: 'ready', search: 'batch-finalize-target', excludedIds: [excluded.id], defaultLibraryId: destination.libraryId, defaultFolderId: destination.libraryFolderId, }, }); expect(response.statusCode).toBe(201); expect(response.json()).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); expect(await getBookDockRow(context, selected.id)).toBeUndefined(); expect(await getBookDockRow(context, excluded.id)).toBeDefined(); expect(await getBookDockRow(context, unselected.id)).toBeDefined(); }); it('finalize marks missing file ids as failures instead of silently skipping them', async () => { const destination = await createLibraryWithFolder(context); const successfulRow = await createBookDockRow(context, { fileName: 'missing-id-success.fb2', selectedMetadata: { title: 'Missing Id Success' }, targetLibraryId: destination.libraryId, targetFolderId: destination.libraryFolderId, }); const missingId = successfulRow.id + 50_000; const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [successfulRow.id, missingId], }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 2, succeeded: 1, failed: 1 }); expect(body.results.find((result) => result.fileId === missingId)).toMatchObject({ success: false, message: 'Book Dock file not found', }); expect(await getBookDockRow(context, successfulRow.id)).toBeUndefined(); }); it('enforces Book Dock permission and library access for finalize', async () => { const noPermissionUser = await createUserAndLogin(context); const accessOnlyUser = await createUserAndLogin(context, { permissions: [Permission.BookDockAccess], }); const withPermissionUser = await createUserAndLogin(context, { permissions: [Permission.BookDockAccess, Permission.LibraryUpload], }); const destination = await createLibraryWithFolder(context); const bookDockRow = await createBookDockRow(context, { fileName: 'permission-check.fb2', selectedMetadata: { title: 'Permission Check' }, status: 'ready', uploadedBy: withPermissionUser.userId, }); const forbiddenSummary = await context.app.inject({ method: 'GET', url: '/api/v1/book-dock/summary', headers: authHeader(noPermissionUser.accessToken), }); expect(forbiddenSummary.statusCode).toBe(403); const allowedSummary = await context.app.inject({ method: 'GET', url: '/api/v1/book-dock/summary', headers: authHeader(withPermissionUser.accessToken), }); expect(allowedSummary.statusCode).toBe(200); const missingUploadPermission = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(accessOnlyUser.accessToken), payload: { fileIds: [bookDockRow.id], defaultLibraryId: destination.libraryId, defaultFolderId: destination.libraryFolderId, }, }); expect(missingUploadPermission.statusCode).toBe(403); expect(missingUploadPermission.json()).toMatchObject({ message: `Missing permission: ${Permission.LibraryUpload}` }); const noAccessFinalize = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(withPermissionUser.accessToken), payload: { fileIds: [bookDockRow.id], defaultLibraryId: destination.libraryId, defaultFolderId: destination.libraryFolderId, }, }); expect(noAccessFinalize.statusCode).toBe(201); const noAccessBody = noAccessFinalize.json() as BookDockFinalizeResult; expect(noAccessBody).toMatchObject({ total: 1, succeeded: 0, failed: 1 }); expect(noAccessBody.results[0]?.message).toContain('No access to this library'); expect(await getBookDockRow(context, bookDockRow.id)).toBeDefined(); await grantLibraryAccess(context, withPermissionUser.userId, destination.libraryId); const withAccessFinalize = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(withPermissionUser.accessToken), payload: { fileIds: [bookDockRow.id], defaultLibraryId: destination.libraryId, defaultFolderId: destination.libraryFolderId, }, }); expect(withAccessFinalize.statusCode).toBe(201); expect(withAccessFinalize.json()).toMatchObject({ total: 1, succeeded: 1, failed: 0 }); expect(await getBookDockRow(context, bookDockRow.id)).toBeUndefined(); }); it('rejects invalid request payloads and supports retry-fetch recovery', async () => { const invalidPayloadResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/bulk-edit', headers: authHeader(context.adminToken), payload: { fileIds: [123], fields: { title: 'Invalid' }, enabledFields: ['title'], mergeArrays: false, unexpected: true, }, }); expect(invalidPayloadResponse.statusCode).toBe(400); const invalidBody = invalidPayloadResponse.json() as { message?: string[] }; expect(invalidBody.message).toContain('property unexpected should not exist'); const targetValidationResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/set-target', headers: authHeader(context.adminToken), payload: { fileIds: [], targetLibraryId: 1, }, }); expect(targetValidationResponse.statusCode).toBe(400); expect(targetValidationResponse.json()).toMatchObject({ message: 'targetLibraryId and targetFolderId must both be set or both be null', }); const finalizeValidationResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [123], defaultLibraryId: 1, }, }); expect(finalizeValidationResponse.statusCode).toBe(400); expect(finalizeValidationResponse.json()).toMatchObject({ message: expect.arrayContaining(['defaultLibraryId and defaultFolderId must either both be provided or both be omitted']), }); const bypassValidationResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [123], overrides: [{ fileId: 123, skipDuplicateCheck: true }], }, }); expect(bypassValidationResponse.statusCode).toBe(400); expect(bypassValidationResponse.json()).toMatchObject({ message: expect.arrayContaining(['overrides.0.property skipDuplicateCheck should not exist']), }); const retriableErrorRow = await createBookDockRow(context, { fileName: 'retry-fetch.fb2', status: 'error', errorMessage: 'Previous metadata failure', content: buildFb2Fixture({ title: 'Retry Fetch Title', authors: ['Retry Author'], }), }); const nonErrorRow = await createBookDockRow(context, { fileName: 'retry-ignore.fb2', status: 'ready', }); const retryResponse = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/files/retry-fetch', headers: authHeader(context.adminToken), payload: { fileIds: [retriableErrorRow.id, nonErrorRow.id], }, }); expect(retryResponse.statusCode).toBe(201); expect(retryResponse.json()).toEqual({ total: 2, queued: 1 }); const retried = await waitForBookDockStatus(context, retriableErrorRow.id, ['ready']); expect(retried.errorMessage).toBeNull(); }); it('rejects unsupported upload formats', async () => { const response = await uploadBookDockFile(context, { token: context.adminToken, fileName: 'unsupported.txt', content: 'not a supported book format', contentType: 'text/plain', }); expect(response.statusCode).toBe(400); expect(response.json()).toMatchObject({ message: expect.stringContaining('Unsupported file type .txt'), }); }); it('returns destination validation failure when folder does not belong to selected library', async () => { const destinationA = await createLibraryWithFolder(context); const destinationB = await createLibraryWithFolder(context); const row = await createBookDockRow(context, { fileName: 'folder-mismatch.fb2', selectedMetadata: { title: 'Folder Mismatch' }, }); const response = await context.app.inject({ method: 'POST', url: '/api/v1/book-dock/finalize', headers: authHeader(context.adminToken), payload: { fileIds: [row.id], defaultLibraryId: destinationA.libraryId, defaultFolderId: destinationB.libraryFolderId, }, }); expect(response.statusCode).toBe(201); const body = response.json() as BookDockFinalizeResult; expect(body).toMatchObject({ total: 1, succeeded: 0, failed: 1 }); expect(body.results[0]?.message).toContain('Folder does not belong to this library'); expect(await getBookDockRow(context, row.id)).toBeDefined(); }); });