Files
bookorbit/server/test/book-dock-ingest-finalize.e2e-spec.ts
T
vishnu d4bc62f8a9 fix(metadata): write finalized metadata back to the imported file (#1499)
* fix(metadata): write finalized metadata back to the imported file

Finalizing a Book Dock file only moved the bytes, so a library with file
writes enabled kept the metadata the file was uploaded with rather than
the metadata the dock settled on. Schedule a write-back for every book a
finalize produces once all of them have their metadata, attributed to
the uploader.

Fixes #1385

* fix(metadata): schedule dock file writes after cleanup

Prevent a failed Book Dock cleanup from leaving a pending file write. Keep the integration assertion synchronized with the debounced writer and restore the missing directory stability mock for end-to-end coverage.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
2026-09-26 09:11:48 -06:00

1238 lines
49 KiB
TypeScript

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 BookDockDiscardDuplicatesResult,
type BookDockFile,
type BookDockFinalizePreviewResult,
type BookDockFinalizeResult,
} from '@bookorbit/types';
import * as schema from '../src/db/schema';
import { parseFb2File } from '../src/modules/metadata/lib/fb2-parser';
import { waitForCondition } from './e2e/app-harness';
import { buildFb2Fixture } from './e2e/book-dock/book-dock-fixture-builder';
import { 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<void> {
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<ReturnType<typeof createLibraryWithFolder>>, fileName: string): Promise<number> {
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' });
});
});
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('finalize stores a folder path even when the naming pattern is flat', async () => {
const destination = await createLibraryWithFolder(context);
await context.db.update(schema.libraries).set({ fileNamingPattern: '{title}' }).where(eq(schema.libraries.id, destination.libraryId));
const bookDockRow = await createBookDockRow(context, {
fileName: 'flat-pattern.fb2',
selectedMetadata: {
title: 'Flat Pattern Title',
authors: ['Flat Pattern 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: [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 [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,
})
.from(schema.bookFiles)
.where(eq(schema.bookFiles.bookId, finalizedBookId))
.limit(1);
expect(book?.folderPath).toBe(destination.folderPath);
expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath));
});
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();
});
});