fix(book-dock): refine duplicate detection for books without isbn

The duplicate detection logic in BookDockFinalizeService previously identified books as duplicates if they had the same title, even if their authors differed, when ISBNs were unavailable. This has been updated to include author names in the duplicate check for books missing ISBNs to prevent incorrect duplicate identifications.

Closes #175
This commit is contained in:
neon
2026-06-01 23:20:34 +00:00
parent 9fd61d327a
commit f20efdeedd
3 changed files with 197 additions and 34 deletions
@@ -365,7 +365,7 @@ describe('Book Dock ingest + finalize (e2e)', () => {
const seedRow = await createBookDockRow(context, {
fileName: 'seed-duplicate.fb2',
selectedMetadata: { title: 'Duplicate Title', authors: ['Duplicate Author'] },
selectedMetadata: { title: 'Duplicate Seed Title', authors: ['Duplicate Author'], isbn13: '9780306406157' },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
@@ -380,7 +380,7 @@ describe('Book Dock ingest + finalize (e2e)', () => {
const duplicateRow = await createBookDockRow(context, {
fileName: 'duplicate-input.fb2',
selectedMetadata: { title: 'Duplicate Title', authors: ['Another Author'] },
selectedMetadata: { title: 'Duplicate Candidate Title', authors: ['Another Author'], isbn13: '9780306406157' },
targetLibraryId: destination.libraryId,
targetFolderId: destination.libraryFolderId,
});
@@ -447,6 +447,50 @@ describe('Book Dock ingest + finalize (e2e)', () => {
expect(await getBookDockRow(context, successRow.id)).toBeUndefined();
});
it('finalize allows same title with different author when isbn is missing', async () => {
const destination = await createLibraryWithFolder(context);
const seedRow = await createBookDockRow(context, {
fileName: 'seed-same-title.fb2',
selectedMetadata: { title: 'Shared Title', authors: ['Author One'] },
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: 'candidate-same-title.fb2',
selectedMetadata: { title: 'Shared Title', authors: ['Author Two'] },
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));