mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(scanner): preserve separate books at the library root
Use file identities for root-level imports and renames to match scanner grouping and prevent unrelated books from sharing metadata. Cover repeated scans, metadata write-back, and stable reading and file identities with regression tests. Fixes #1599
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { resolveSingleFileBookPath } from './book-path.utils';
|
||||
|
||||
describe('resolveSingleFileBookPath', () => {
|
||||
it.each(['book_per_folder', 'book_per_file', null, undefined])('separates root files with organizationMode=%s', (mode) => {
|
||||
expect(resolveSingleFileBookPath('/library/first.epub', '/library', mode)).toBe('/library/first.epub');
|
||||
expect(resolveSingleFileBookPath('/library/second.epub', '/library', mode)).toBe('/library/second.epub');
|
||||
});
|
||||
|
||||
it.each(['/library/', '/library/./', '/library/unused/..'])('recognizes equivalent library roots: %s', (root) => {
|
||||
expect(resolveSingleFileBookPath('/library/book.epub', root, 'book_per_folder')).toBe('/library/book.epub');
|
||||
});
|
||||
|
||||
it.each(['book_per_folder', null, undefined])('groups formats inside a book folder with organizationMode=%s', (mode) => {
|
||||
expect(resolveSingleFileBookPath('/library/Author/Book/book.epub', '/library', mode)).toBe('/library/Author/Book');
|
||||
expect(resolveSingleFileBookPath('/library/Author/Book/book.pdf', '/library', mode)).toBe('/library/Author/Book');
|
||||
});
|
||||
|
||||
it('keeps nested files separate in File as Book mode', () => {
|
||||
expect(resolveSingleFileBookPath('/library/Author/book.epub', '/library', 'book_per_file')).toBe('/library/Author/book.epub');
|
||||
expect(resolveSingleFileBookPath('/library/Author/book.pdf', '/library', 'book_per_file')).toBe('/library/Author/book.pdf');
|
||||
});
|
||||
|
||||
it('does not mistake a nested or similarly prefixed directory for the library root', () => {
|
||||
expect(resolveSingleFileBookPath('/library/Author/book.epub', '/library', 'book_per_folder')).toBe('/library/Author');
|
||||
expect(resolveSingleFileBookPath('/library-other/Book/book.epub', '/library', 'book_per_folder')).toBe('/library-other/Book');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
export function resolveSingleFileBookPath(absolutePath: string, libraryFolderPath: string, organizationMode: string | null | undefined): string {
|
||||
const parent = dirname(absolutePath);
|
||||
// The scanner treats root-level content files as separate books even in Folder as Book mode.
|
||||
return organizationMode === 'book_per_file' || resolve(parent) === resolve(libraryFolderPath) ? absolutePath : parent;
|
||||
}
|
||||
@@ -763,6 +763,71 @@ describe('BookDockFinalizeService', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(['book_per_folder', 'book_per_file'])('keeps unrelated root imports separate in %s mode', async (organizationMode) => {
|
||||
const { service, processor, storage } = makeService();
|
||||
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
|
||||
id: 5,
|
||||
allowedFormats: ['epub'],
|
||||
fileNamingPattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>',
|
||||
organizationMode,
|
||||
} as never);
|
||||
vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never);
|
||||
const applyMetadata = vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
|
||||
mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
mockStat.mockResolvedValue({ size: 100 } as never);
|
||||
|
||||
for (const [index, title] of ['First Book', 'Second Book'].entries()) {
|
||||
processor.createUnitBookRecords.mockResolvedValueOnce({ bookIds: [101 + index], createdBookIds: [101 + index], attachedFileIds: [] });
|
||||
const row = makeRow({
|
||||
id: index + 1,
|
||||
targetLibraryId: 5,
|
||||
targetFolderId: 9,
|
||||
selectedMetadata: { title, authors: ['Test Author'], publishedYear: 2024 },
|
||||
});
|
||||
const result = await (service as any).finalizeFile(row, undefined, undefined, new Map(), 1, true);
|
||||
|
||||
expect(result).toMatchObject({ success: true, bookId: 101 + index, newName: `${title} - Test Author - 2024.epub` });
|
||||
const destPath = `/library/${title} - Test Author - 2024.epub`;
|
||||
expect(storage.moveToPath).toHaveBeenNthCalledWith(index + 1, row.absolutePath, destPath);
|
||||
expect(processor.createUnitBookRecords).toHaveBeenNthCalledWith(index + 1, 5, 9, [
|
||||
expect.objectContaining({ folderPath: destPath, absolutePath: destPath }),
|
||||
]);
|
||||
expect(applyMetadata).toHaveBeenNthCalledWith(index + 1, 101 + index, row, true);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the persisted filename spelling for a root book identity', async () => {
|
||||
const { service, processor } = makeService();
|
||||
vi.spyOn(service as never, 'findLibraryOrFail').mockResolvedValue({
|
||||
id: 5,
|
||||
allowedFormats: ['epub'],
|
||||
fileNamingPattern: '{title}',
|
||||
organizationMode: 'book_per_folder',
|
||||
} as never);
|
||||
vi.spyOn(service as never, 'findFolderOrFail').mockResolvedValue({ id: 9, libraryId: 5, path: '/library' } as never);
|
||||
vi.spyOn(service as never, 'applyMetadata').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(service as never, 'cleanupBookDockRecord').mockResolvedValue(undefined as never);
|
||||
mockAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
|
||||
mockStat.mockResolvedValue({ size: 100 } as never);
|
||||
mockLstat.mockResolvedValue({} as never);
|
||||
mockReaddir.mockResolvedValue(['dune.epub'] as never);
|
||||
|
||||
const result = await (service as any).finalizeFile(
|
||||
makeRow({ targetLibraryId: 5, targetFolderId: 9, selectedMetadata: { title: 'Dune' } }),
|
||||
undefined,
|
||||
undefined,
|
||||
new Map(),
|
||||
1,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ success: true, newName: 'dune.epub' });
|
||||
expect(processor.createUnitBookRecords).toHaveBeenCalledWith(5, 9, [
|
||||
expect.objectContaining({ folderPath: '/library/dune.epub', absolutePath: '/library/dune.epub' }),
|
||||
]);
|
||||
});
|
||||
|
||||
describe('organization modes', () => {
|
||||
it('attaches pdf to existing book folder in book_per_folder mode', async () => {
|
||||
const { service, processor } = makeService();
|
||||
|
||||
@@ -47,6 +47,7 @@ import { SeriesIdentityService } from '../../common/services/series-identity.ser
|
||||
import { SeriesMembershipService } from '../../common/services/series-membership.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
|
||||
import { resolveSingleFileBookPath } from '../../common/utils/book-path.utils';
|
||||
import { normalizePublishedDate, publishedYearFromDateKey } from '../../common/utils/published-date.utils';
|
||||
import { buildPatternTokens, patternReferencesToken } from '../../common/utils/pattern-tokens.utils';
|
||||
import { DB } from '../../db';
|
||||
@@ -665,7 +666,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
if (unitFiles.length === 0) {
|
||||
return {
|
||||
files: [{ sourcePath: row.absolutePath, destPath, format: row.format, role: 'content', sortOrder: 0 }],
|
||||
bookFolderPath: library.organizationMode === 'book_per_file' ? destPath : dirname(destPath),
|
||||
bookFolderPath: resolveSingleFileBookPath(destPath, folderPath, library.organizationMode),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -750,7 +751,7 @@ export class BookDockFinalizeService implements OnModuleInit, OnApplicationBoots
|
||||
if (placement.length === 0) throw new Error('Finalization placed no files');
|
||||
|
||||
const loose = library.organizationMode === 'book_per_file';
|
||||
const sharedFolderPath = bookFolderPath ?? dirname(persistedPrimaryPath);
|
||||
const sharedFolderPath = bookFolderPath === placement[0]!.destPath ? persistedPrimaryPath : (bookFolderPath ?? dirname(persistedPrimaryPath));
|
||||
const files: UnitBookFileInput[] = [];
|
||||
|
||||
for (const [index, file] of placement.entries()) {
|
||||
|
||||
@@ -1160,6 +1160,29 @@ describe('FileRenameService', () => {
|
||||
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/Dune/Dune.epub', '/library/Frank Herbert/Dune.epub');
|
||||
});
|
||||
|
||||
it.each(['book_per_folder', 'book_per_file'])('preserves a root file identity after renaming in %s mode', async (organizationMode) => {
|
||||
const { service, renameRepo } = makeService();
|
||||
renameRepo.findBookRenameData.mockResolvedValue(
|
||||
makeRenameData({
|
||||
organizationMode,
|
||||
fileNamingPattern: '{title}',
|
||||
file: { absolutePath: '/library/Old Title.epub', relPath: 'Old Title.epub' },
|
||||
bookFolderPath: '/library/Old Title.epub',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.performRename(5, 12);
|
||||
|
||||
expect(result).toMatchObject({ status: 'success', newPath: '/library/Dune.epub' });
|
||||
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
|
||||
5,
|
||||
[{ id: 10, absolutePath: '/library/Dune.epub', relPath: 'Dune.epub' }],
|
||||
'/library/Dune.epub',
|
||||
);
|
||||
expect(renameRepo.findFolderOwners).not.toHaveBeenCalled();
|
||||
expect(mockRename).toHaveBeenCalledWith('/library/Old Title.epub', '/library/Dune.epub');
|
||||
});
|
||||
|
||||
it('moves a flat file into a new per-book folder when bookFolderPath equals the file path', async () => {
|
||||
const { service, renameRepo } = makeService();
|
||||
renameRepo.findBookRenameData.mockResolvedValue(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { NotificationType, resolveUploadPath, sanitizePathSegment } from '@booko
|
||||
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { pathsReferToSameEntry } from '../../common/utils/path-identity.utils';
|
||||
import { resolveSingleFileBookPath } from '../../common/utils/book-path.utils';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import type { BookCoverSlotRow } from '../book-cover-store/book-cover-store.repository';
|
||||
import { BookCoverStore } from '../book-cover-store/book-cover-store.service';
|
||||
@@ -156,6 +157,7 @@ export class FileRenameService implements OnModuleDestroy {
|
||||
|
||||
const newAbsolutePath = fileTargets.get(data.file.id) ?? baseNewAbsolutePath;
|
||||
const newFolderPath = dirname(newAbsolutePath);
|
||||
const newBookPath = isBookPerFolder ? resolveSingleFileBookPath(newAbsolutePath, data.libraryFolderPath, data.organizationMode) : newAbsolutePath;
|
||||
|
||||
let pathUnchanged = newAbsolutePath === currentAbsolutePath;
|
||||
if (pathUnchanged) {
|
||||
@@ -174,7 +176,7 @@ export class FileRenameService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
let mergeTargetBookId: number | null = null;
|
||||
if (isBookPerFolder && newFolderPath !== currentFolderPath) {
|
||||
if (isBookPerFolder && newBookPath === newFolderPath && newFolderPath !== currentFolderPath) {
|
||||
const owner = (await this.renameRepo.findFolderOwners(data.libraryId, [newFolderPath])).get(newFolderPath);
|
||||
if (owner && owner.bookId !== bookId) {
|
||||
const sameWork = isSameWork({ title: data.metadata.title, primaryAuthor: data.authors[0] ?? null }, owner);
|
||||
@@ -263,8 +265,7 @@ export class FileRenameService implements OnModuleDestroy {
|
||||
await this.renameBookWithFolder(bookId, data, currentFolderPath, newFolderPath, fileTargets, sanitizeForCrossPlatform);
|
||||
}
|
||||
} else {
|
||||
const nextBookFolderPath = isBookPerFolder ? newFolderPath : newAbsolutePath;
|
||||
await this.renameBookFilesOnly(bookId, data, fileTargets, nextBookFolderPath, sanitizeForCrossPlatform);
|
||||
await this.renameBookFilesOnly(bookId, data, fileTargets, newBookPath, sanitizeForCrossPlatform);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
|
||||
@@ -878,6 +878,44 @@ describe('UploadService', () => {
|
||||
expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: 'book_per_folder', pattern: '{title}', global: false },
|
||||
{ mode: 'book_per_folder', pattern: '<{title}|{originalFilename}> - <{authors:first}> - <{year}>', global: false },
|
||||
{ mode: 'book_per_folder', pattern: '{title}', global: true },
|
||||
{ mode: 'book_per_file', pattern: '{title}', global: false },
|
||||
])('keeps sequential root uploads separate with $mode, pattern=$pattern, global=$global', async ({ mode, pattern, global }) => {
|
||||
for (const title of ['First Book', 'Second Book']) {
|
||||
db.select
|
||||
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: global ? null : pattern, organizationMode: mode }]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
|
||||
appSettings.getUploadPatternBookPerFolder.mockResolvedValue(pattern);
|
||||
mockExtractEpubMetadata.mockResolvedValue({ title, authors: [{ name: 'Test Author' }], publishedYear: 2024 } as never);
|
||||
|
||||
await service.upload(1, 2, 'raw.epub', {} as any, user);
|
||||
}
|
||||
|
||||
const paths = pattern.includes('authors')
|
||||
? ['/library/First Book - Test Author - 2024.epub', '/library/Second Book - Test Author - 2024.epub']
|
||||
: ['/library/First Book.epub', '/library/Second Book.epub'];
|
||||
for (const [index, path] of paths.entries()) {
|
||||
expect(storage.moveToPath).toHaveBeenNthCalledWith(index + 1, '/tmp/upload.bin', path);
|
||||
expect(processor.createBookRecord).toHaveBeenNthCalledWith(index + 1, 1, 2, path, path, path.slice('/library/'.length), 'epub', 456);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the persisted root filename spelling for both file and book identity', async () => {
|
||||
db.select
|
||||
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}', organizationMode: 'book_per_folder' }]))
|
||||
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
|
||||
mockExtractEpubMetadata.mockResolvedValue({ title: 'Dune', authors: [] } as never);
|
||||
mockLstat.mockResolvedValue({} as never);
|
||||
mockReaddir.mockResolvedValue(['dune.epub'] as never);
|
||||
|
||||
await service.upload(1, 2, 'raw.epub', {} as any, user);
|
||||
|
||||
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/dune.epub', '/library/dune.epub', 'dune.epub', 'epub', 456);
|
||||
});
|
||||
|
||||
it('book_per_folder global pattern used when library pattern is null', async () => {
|
||||
db.select
|
||||
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_folder' }]))
|
||||
|
||||
@@ -29,6 +29,7 @@ import { parsePdfFile, type PdfParseWarning } from '../metadata/lib/pdf-parser';
|
||||
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
|
||||
import { computeFileHash } from '../scanner/lib/hash';
|
||||
import { resolveExistingPathSpelling } from '../../common/utils/path-identity.utils';
|
||||
import { resolveSingleFileBookPath } from '../../common/utils/book-path.utils';
|
||||
import { inspectEpubMediaOverlayFields, mediaOverlayCapabilityFromFields } from '../reader/epub/epub-media-overlay-capability';
|
||||
import { PathPolicyService } from '../path/path-policy.service';
|
||||
import { FORMATS_WITH_UNBOUNDED_METADATA_READS, MAX_BUFFERED_METADATA_BYTES } from '../../common/constants/upload.constants';
|
||||
@@ -426,7 +427,7 @@ export class UploadService {
|
||||
|
||||
if (resolved) {
|
||||
const absolutePath = join(libraryFolderPath, resolved);
|
||||
return { absolutePath, bookFolderPath: isBookPerFile ? absolutePath : dirname(absolutePath) };
|
||||
return { absolutePath, bookFolderPath: resolveSingleFileBookPath(absolutePath, libraryFolderPath, library.organizationMode) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
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 { createPdfFixture } from './e2e/metadata-write/metadata-write-fixture-builder';
|
||||
@@ -809,51 +810,107 @@ describe('Book Dock ingest + finalize (e2e)', () => {
|
||||
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));
|
||||
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 bookDockRow = await createBookDockRow(context, {
|
||||
fileName: 'flat-pattern.fb2',
|
||||
selectedMetadata: {
|
||||
title: 'Flat Pattern Title',
|
||||
authors: ['Flat Pattern Author'],
|
||||
},
|
||||
targetLibraryId: destination.libraryId,
|
||||
targetFolderId: destination.libraryFolderId,
|
||||
});
|
||||
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();
|
||||
|
||||
const response = await context.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/book-dock/finalize',
|
||||
headers: authHeader(context.adminToken),
|
||||
payload: {
|
||||
fileIds: [bookDockRow.id],
|
||||
},
|
||||
});
|
||||
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`);
|
||||
}
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const body = response.json() as BookDockFinalizeResult;
|
||||
expect(body).toMatchObject({ total: 1, succeeded: 1, failed: 0 });
|
||||
if (label === 'First') {
|
||||
await context.db.insert(schema.userBookStatus).values({ userId: reader.userId, bookId, status: 'reading', source: 'manual' });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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] })));
|
||||
|
||||
expect(book?.folderPath).toBe(destination.folderPath);
|
||||
expect(book?.folderPath).toBe(dirname(bookFile!.absolutePath));
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user