mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
d4bc62f8a9
* 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>
1238 lines
49 KiB
TypeScript
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();
|
|
});
|
|
});
|