Files
bookorbit/server/test/book-dock-ingest-finalize.e2e-spec.ts
T
neon 4d453c82ca fix(books): measure audiobook durations when Book Dock files them
Book Dock finalize, which also files book requests, left each audio file's
duration empty until the next library scan. The player derives progress only
from those, so a filed audiobook stayed at 0% and never reached Continue
Listening. Finalize now measures them once metadata is applied.

Refs #1757
2026-10-12 03:44:40 +00:00

1424 lines
58 KiB
TypeScript

// 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<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' });
});
});
/**
* 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<number> {
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<ReturnType<typeof createLibraryWithFolder>>, 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<AudiobookPlaybackState> {
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<number[]> {
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();
});
});