Files
bookorbit/server/src/modules/upload/upload.service.test.ts
T
neon 688cfdeaec fix(scanner): keep audiobook tracks in file name order
The player follows each track's stored sort order, which overlapping
folder scans, Add File, and imports joining a book could leave out of
step with the file names the Files tab shows. Realign a book's tracks
after each of those writes, under the book row lock.

Closes #1681
2026-10-12 05:05:13 +00:00

1470 lines
63 KiB
TypeScript

vi.mock('fs/promises', async () => {
const actual = await vi.importActual('fs/promises');
return { ...actual, access: vi.fn(), lstat: vi.fn(), readdir: vi.fn(), stat: vi.fn() };
});
vi.mock('../scanner/lib/hash', () => ({ computeFileHash: vi.fn() }));
vi.mock('../../db/audio-track-order', () => ({ realignAudioTrackOrder: vi.fn() }));
vi.mock('../metadata/lib/epub', () => ({ extractEpubMetadata: vi.fn() }));
vi.mock('../metadata/lib/cbz-metadata', () => ({ extractCbzMetadata: vi.fn(), extractCbrMetadata: vi.fn(), extractCb7Metadata: vi.fn() }));
vi.mock('../metadata/lib/mobi-parser', () => ({ parseMobiFile: vi.fn() }));
vi.mock('../metadata/lib/pdf-parser', () => ({ parsePdfFile: vi.fn() }));
import { access as fsAccess, lstat, readdir, stat } from 'fs/promises';
import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE } from '@bookorbit/types';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata } from '../metadata/lib/cbz-metadata';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
import { computeFileHash } from '../scanner/lib/hash';
import { realignAudioTrackOrder } from '../../db/audio-track-order';
import { books } from '../../db/schema';
import { UploadService } from './upload.service';
const mockFsAccess = fsAccess as MockedFunction<typeof fsAccess>;
const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction<typeof realignAudioTrackOrder>;
const mockLstat = lstat as MockedFunction<typeof lstat>;
const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockStat = stat as MockedFunction<typeof stat>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
const mockComputeFileHash = computeFileHash as MockedFunction<typeof computeFileHash>;
function selectChain(rows: unknown[]) {
const whereResult: PromiseLike<unknown[]> & { limit: vi.Mock } = {
limit: vi.fn().mockResolvedValue(rows),
then: (resolve) => Promise.resolve(resolve(rows)),
};
return {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue(whereResult),
}),
};
}
function lockedBookSelectChain(rows: unknown[], forUpdateMock: ReturnType<typeof vi.fn>) {
const limitMock = vi.fn().mockResolvedValue(rows);
forUpdateMock.mockReturnValue({ limit: limitMock });
const whereMock = vi.fn().mockReturnValue({ for: forUpdateMock });
const joinMock = vi.fn().mockReturnValue({ where: whereMock });
return {
from: vi.fn().mockReturnValue({ innerJoin: joinMock }),
};
}
function contentFilesSelectChain(rows: unknown[]) {
const orderByMock = vi.fn().mockResolvedValue(rows);
const whereMock = vi.fn().mockReturnValue({ orderBy: orderByMock });
return {
from: vi.fn().mockReturnValue({ where: whereMock }),
};
}
describe('UploadService', () => {
const insertReturning = vi.fn();
const insertValues = vi.fn();
const updateSet = vi.fn();
const updateWhere = vi.fn();
const forUpdate = vi.fn();
const tx = {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
};
const db = {
select: vi.fn(),
insert: vi.fn(() => ({ values: insertValues })),
update: vi.fn(() => ({ set: updateSet })),
transaction: vi.fn(),
};
const appSettings = { getUploadPattern: vi.fn(), getUploadPatternBookPerFolder: vi.fn(), isCrossPlatformPathSanitizationEnabled: vi.fn() };
const libraryService = { verifyUserAccess: vi.fn() };
const validator = {
sanitizeFilename: vi.fn(),
validateFormat: vi.fn(),
validateContent: vi.fn(),
};
const storage = {
streamToTemp: vi.fn(),
moveToPath: vi.fn(),
cleanup: vi.fn(),
};
const processor = {
createBookRecord: vi.fn(),
processNewBookImportAsync: vi.fn(),
extractMetadataAsync: vi.fn(),
extractAudioDurationAsync: vi.fn(),
extractAddedAudioChaptersAsync: vi.fn(),
reconcileCoversAsync: vi.fn(),
};
const user = { id: 7, isSuperuser: false, permissions: [] } as any;
const moduleRef = { get: vi.fn().mockReturnValue(null) };
const pathPolicy = { assertWithinRoot: vi.fn() };
let service: UploadService;
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
service = new UploadService(
db as any,
appSettings as any,
libraryService as any,
validator as any,
storage as any,
processor as any,
moduleRef as any,
pathPolicy as any,
);
validator.sanitizeFilename.mockReturnValue('book.epub');
validator.validateFormat.mockReturnValue('epub');
validator.validateContent.mockResolvedValue(undefined);
pathPolicy.assertWithinRoot.mockResolvedValue('/library/book.epub');
storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 456 });
storage.moveToPath.mockResolvedValue(undefined);
storage.cleanup.mockResolvedValue(undefined);
processor.createBookRecord.mockResolvedValue({ bookId: 99, created: true });
libraryService.verifyUserAccess.mockResolvedValue(undefined);
appSettings.getUploadPattern.mockResolvedValue(null);
appSettings.getUploadPatternBookPerFolder.mockResolvedValue(null);
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false);
mockFsAccess.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockLstat.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' }));
mockReaddir.mockResolvedValue([]);
mockExtractEpubMetadata.mockResolvedValue(null);
// addFileToBook defaults
tx.insert.mockReturnValue({ values: insertValues });
tx.update.mockReturnValue({ set: updateSet });
db.transaction.mockImplementation((callback) => callback(tx));
insertValues.mockReturnValue({ returning: insertReturning });
insertReturning.mockResolvedValue([
{
id: 55,
format: 'epub',
role: 'content',
sizeBytes: 456,
absolutePath: '/library/Book/book.epub',
createdAt: new Date('2025-01-01'),
durationSeconds: null,
},
]);
updateSet.mockReturnValue({ where: updateWhere });
updateWhere.mockResolvedValue(undefined);
tx.select
.mockReturnValueOnce(lockedBookSelectChain([{ primaryFileId: 99, status: 'present', formatPriority: ['epub', 'm4b', 'mp3'] }], forUpdate))
.mockReturnValueOnce(
contentFilesSelectChain([
{ id: 55, format: 'epub', sizeBytes: 456 },
{ id: 99, format: 'epub', sizeBytes: 1000 },
]),
);
mockComputeFileHash.mockResolvedValue('hash-abc');
mockStat.mockResolvedValue({ ino: 12345n, mtime: new Date('2025-01-01') } as any);
});
it('uploads successfully and processes a new-book import after the record is created', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(
1,
2,
'/library/Frank Herbert',
'/library/Frank Herbert/Dune.epub',
'Frank Herbert/Dune.epub',
'epub',
456,
);
expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/Frank Herbert/Dune.epub', 'epub');
});
it('inspects and marks a read-aloud EPUB when the upload pattern references the token', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}< ({readaloud})>' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const inspect = vi.spyOn(service as any, 'inspectMediaOverlayFields').mockResolvedValue({
mediaOverlayAvailable: true,
mediaOverlayDurationSeconds: 3600,
mediaOverlayCheckedAt: new Date(),
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result.filename).toBe('Dune (readaloud).epub');
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Dune (readaloud).epub');
expect(inspect).toHaveBeenCalledOnce();
});
it('does not inspect media overlays for naming when the upload pattern omits the token', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const inspect = vi.spyOn(service as any, 'inspectMediaOverlayFields');
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(inspect).not.toHaveBeenCalled();
});
it('stores the directory-entry spelling when an upload targets a case-insensitive parent path', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Bell Hooks' }],
tags: [],
description: null,
isbn10: null,
});
mockLstat.mockResolvedValue({} as never);
mockReaddir.mockImplementation((path) => {
const entriesByPath: Record<string, string[]> = {
'/': ['library'],
'/library': ['bell hooks'],
'/library/bell hooks': ['Dune.epub'],
};
return Promise.resolve(entriesByPath[String(path)] ?? []) as never;
});
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(mockReaddir.mock.calls.map((call) => call[0])).toEqual(['/library', '/library/bell hooks']);
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Bell Hooks/Dune.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(
1,
2,
'/library/bell hooks',
'/library/bell hooks/Dune.epub',
'bell hooks/Dune.epub',
'epub',
456,
);
expect(processor.processNewBookImportAsync).toHaveBeenCalledWith(99, 1, '/library/bell hooks/Dune.epub', 'epub');
});
it('only extracts metadata when an upload adds a file to an existing book', async () => {
processor.createBookRecord.mockResolvedValue({ bookId: 99, created: false });
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(processor.extractMetadataAsync).toHaveBeenCalledWith(99, '/library/book/book.epub', 'epub');
expect(processor.processNewBookImportAsync).not.toHaveBeenCalled();
});
it('sanitizes generated destination path tokens when cross-platform mode is enabled', async () => {
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(true);
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'AUX',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'CON' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'AUX_.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/CON_/AUX_.epub');
});
it('falls back to stem folder when no naming pattern is configured', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result.filename).toBe('book.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/book', '/library/book/book.epub', 'book/book.epub', 'epub', 456);
});
it('falls back to stem folder when metadata token extraction fails', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockRejectedValue(new Error('metadata parser broke'));
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub');
});
it('throws ConflictException when destination already exists and cleans temp path', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockFsAccess.mockResolvedValue(undefined);
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/book/book.epub');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('cleans up both paths when move fails', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
storage.moveToPath.mockRejectedValue(new Error('disk full'));
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toThrow('disk full');
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).toHaveBeenCalledWith('/library/book/book.epub');
});
it('cleans destination when DB write fails after move', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
processor.createBookRecord.mockRejectedValue(new Error('insert failed'));
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toThrow('insert failed');
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).toHaveBeenCalledWith('/library/book/book.epub');
});
it('propagates access errors other than ENOENT', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockFsAccess.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toThrow('permission denied');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('rejects folder IDs that do not belong to the selected library', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 999, path: '/wrong' }]));
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('chooses a deterministic default folder by smallest ID', async () => {
db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])).mockReturnValueOnce(
selectChain([
{ id: 20, libraryId: 1, path: '/folder-b' },
{ id: 2, libraryId: 1, path: '/folder-a' },
]),
);
await service.upload(1, undefined, 'raw.epub', {} as any, user);
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/folder-a/book', '/folder-a/book/book.epub', 'book/book.epub', 'epub', 456);
});
it('rejects uploads when no default folder exists for the library', async () => {
db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])).mockReturnValueOnce(selectChain([]));
await expect(service.upload(1, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('book_per_file with a folderless pattern keeps the file at the library root', async () => {
db.select
.mockReturnValueOnce(
selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}.{extension}', organizationMode: 'book_per_file' }]),
)
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Dune.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/Dune.epub', '/library/Dune.epub', 'Dune.epub', 'epub', 456);
});
it('book_per_file keeps the folder segments its pattern defines', async () => {
db.select
.mockReturnValueOnce(
selectChain([
{
id: 1,
allowedFormats: ['epub'],
fileNamingPattern: '{authors:first}/{series}/{title}.{extension}',
organizationMode: 'book_per_file',
},
]),
)
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: 'Dune',
seriesIndex: null,
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Dune.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune/Dune.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(
1,
2,
'/library/Frank Herbert/Dune/Dune.epub',
'/library/Frank Herbert/Dune/Dune.epub',
'Frank Herbert/Dune/Dune.epub',
'epub',
456,
);
});
it('book_per_file files under the shipped default pattern rather than at the library root', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
appSettings.getUploadPattern.mockResolvedValue(DEFAULT_UPLOAD_PATTERN_BOOK_PER_FILE);
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: 1965,
language: null,
seriesName: 'Dune',
seriesIndex: '1',
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Frank Herbert/Dune/01. Dune (1965).epub');
});
it('book_per_file without pattern uses filename directly', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/book.epub', '/library/book.epub', 'book.epub', 'epub', 456);
});
it('book_per_file falls back to filename when pattern resolves to null', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}', organizationMode: 'book_per_file' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue(null);
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book.epub');
expect(processor.createBookRecord).toHaveBeenCalledWith(1, 2, '/library/book.epub', '/library/book.epub', 'book.epub', 'epub', 456);
});
it('book_per_file throws ConflictException when destination exists', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockFsAccess.mockResolvedValue(undefined);
await expect(service.upload(1, 2, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('mobi metadata populates tokens', async () => {
validator.validateFormat.mockReturnValue('mobi');
validator.sanitizeFilename.mockReturnValue('book.mobi');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['mobi'], fileNamingPattern: '{authors:first}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockParseMobiFile.mockResolvedValue({
title: 'The Stand',
authors: ['Stephen King'],
publisher: 'Doubleday',
isbn: '9780385121682',
publishedDate: '1978-10-03',
language: 'en',
description: null,
tags: [],
coverRecordIndex: null,
recordOffsets: [],
});
const result = await service.upload(1, 2, 'raw.mobi', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'The Stand.mobi', format: 'mobi', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Stephen King/The Stand.mobi');
});
it.each(['9780385121682', '097522980X'])('mobi metadata populates the ISBN naming token: %s', async (isbn) => {
validator.validateFormat.mockReturnValue('mobi');
validator.sanitizeFilename.mockReturnValue('book.mobi');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['mobi'], fileNamingPattern: '{authors:first}/{title}-{isbn}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockParseMobiFile.mockResolvedValue({
title: 'The Stand',
authors: ['Stephen King'],
publisher: 'Doubleday',
isbn,
publishedDate: '1978-10-03',
language: 'en',
description: null,
tags: [],
});
storage.streamToTemp.mockResolvedValue({ tempPath: 'upload.bin', sizeBytes: 456 });
const result = await service.upload(1, 2, 'raw.mobi', {} as any, user);
expect(result.filename).toBe(`The Stand-${isbn}.mobi`);
expect(storage.moveToPath.mock.lastCall?.[1]).toBe(`/library/Stephen King/The Stand-${isbn}.mobi`);
});
it('pdf metadata populates tokens', async () => {
validator.validateFormat.mockReturnValue('pdf');
validator.sanitizeFilename.mockReturnValue('doc.pdf');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['pdf'], fileNamingPattern: '{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockParsePdfFile.mockResolvedValue({
title: 'Research Paper',
authors: [{ name: 'Jane Doe', sortName: null }],
publisher: 'Academic Press',
subtitle: null,
description: null,
publishedYear: null,
language: null,
genres: [],
tags: [],
isbn10: null,
isbn13: null,
seriesName: null,
seriesIndex: null,
rating: null,
pageCount: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
});
const result = await service.upload(1, 2, 'raw.pdf', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Research Paper.pdf', format: 'pdf', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Research Paper.pdf');
expect(mockParsePdfFile).toHaveBeenCalledWith(
'/tmp/upload.bin',
expect.objectContaining({
extractCover: false,
onWarning: expect.any(Function),
}),
);
});
it('cbz metadata populates tokens', async () => {
validator.validateFormat.mockReturnValue('cbz');
validator.sanitizeFilename.mockReturnValue('comic.cbz');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['cbz'], fileNamingPattern: '{series}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractCbzMetadata.mockResolvedValue({
title: 'Issue 1',
seriesName: 'Batman',
seriesIndex: '1',
authors: [{ name: 'Bob Kane', sortName: null }],
subtitle: null,
description: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
rating: null,
isbn10: null,
isbn13: null,
genres: [],
tags: [],
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
comicMetadata: null,
});
const result = await service.upload(1, 2, 'raw.cbz', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Issue 1.cbz', format: 'cbz', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Batman/Issue 1.cbz');
});
it('unsupported format returns base tokens only', async () => {
validator.validateFormat.mockReturnValue('fb2');
validator.sanitizeFilename.mockReturnValue('book.fb2');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['fb2'], fileNamingPattern: '{title}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
const result = await service.upload(1, 2, 'raw.fb2', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'book.fb2', format: 'fb2', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.fb2');
});
it('series index formatting - whole number zero-padded', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{series} {seriesIndex}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Book Three',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: 'Trilogy',
seriesIndex: '3',
isbn13: null,
authors: [{ name: 'Author' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Book Three.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Trilogy 03/Book Three.epub');
});
it('series index formatting - decimal preserved', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{series} {seriesIndex}/{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue({
title: 'Book Three',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: 'Trilogy',
seriesIndex: '5.10',
isbn13: null,
authors: [{ name: 'Author' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'Book Three.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Trilogy 05.10/Book Three.epub');
});
it('mobi parser throws - logs warning and returns base tokens', async () => {
validator.validateFormat.mockReturnValue('mobi');
validator.sanitizeFilename.mockReturnValue('book.mobi');
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['mobi'], fileNamingPattern: '{title}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockParseMobiFile.mockRejectedValue(new Error('mobi parse error'));
const result = await service.upload(1, 2, 'raw.mobi', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'book.mobi', format: 'mobi', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.mobi');
});
it('throws NotFoundException when library does not exist', async () => {
db.select.mockReturnValueOnce(selectChain([]));
await expect(service.upload(999, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(NotFoundException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws when user has no access to library', async () => {
db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }]));
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
await expect(service.upload(1, undefined, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(ForbiddenException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException when folder does not exist', async () => {
db.select.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null }])).mockReturnValueOnce(selectChain([]));
await expect(service.upload(1, 999, 'raw.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('library pattern takes precedence over global upload pattern', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}.{extension}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
appSettings.getUploadPattern.mockResolvedValue('{authors:first}/{title}.{extension}');
mockExtractEpubMetadata.mockResolvedValue({
title: 'MyBook',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [{ name: 'AuthorX' }],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'MyBook.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/MyBook.epub');
expect(appSettings.getUploadPattern).not.toHaveBeenCalled();
expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled();
});
it('book_per_file global pattern used when library pattern is null', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: null, organizationMode: 'book_per_file' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
appSettings.getUploadPattern.mockResolvedValue('{title}.{extension}');
mockExtractEpubMetadata.mockResolvedValue({
title: 'GlobalBook',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [],
tags: [],
description: null,
isbn10: null,
});
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'GlobalBook.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/GlobalBook.epub');
expect(appSettings.getUploadPattern).toHaveBeenCalled();
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' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{title}/');
mockExtractEpubMetadata.mockResolvedValue({
title: 'FolderBook',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: [],
tags: [],
description: null,
isbn10: null,
});
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/FolderBook/book.epub');
expect(appSettings.getUploadPatternBookPerFolder).toHaveBeenCalled();
expect(appSettings.getUploadPattern).not.toHaveBeenCalled();
});
it('book_per_folder library pattern still wins over folder-mode global pattern', async () => {
db.select
.mockReturnValueOnce(
selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}/{title}.{extension}', organizationMode: 'book_per_folder' }]),
)
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{series}/{title}/');
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: 'Dune Chronicles',
seriesIndex: '1',
isbn13: null,
authors: [{ name: 'Frank Herbert' }],
tags: [],
description: null,
isbn10: null,
});
await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Dune/Dune.epub');
expect(appSettings.getUploadPatternBookPerFolder).not.toHaveBeenCalled();
expect(appSettings.getUploadPattern).not.toHaveBeenCalled();
});
it('pattern resolves to null falls back to stem-folder layout', async () => {
db.select
.mockReturnValueOnce(selectChain([{ id: 1, allowedFormats: ['epub'], fileNamingPattern: '{title}' }]))
.mockReturnValueOnce(selectChain([{ id: 2, libraryId: 1, path: '/library' }]));
mockExtractEpubMetadata.mockResolvedValue(null);
const result = await service.upload(1, 2, 'raw.epub', {} as any, user);
expect(result).toEqual({ bookId: 99, filename: 'book.epub', format: 'epub', sizeBytes: 456 });
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/book/book.epub');
});
describe('addFileToBook', () => {
function makeBookRow(overrides: Record<string, unknown> = {}) {
return {
id: 10,
folderPath: '/library/Book Title',
libraryId: 1,
libraryFolderId: 2,
primaryFileId: 99,
status: 'present',
allowedFormats: [] as string[],
organizationMode: 'book_per_folder',
libraryFolderPath: '/library',
...overrides,
};
}
function selectJoinChain(rows: unknown[]) {
const limitMock = vi.fn().mockResolvedValue(rows);
const whereMock = vi.fn().mockReturnValue({ limit: limitMock });
const joinMock2 = vi.fn().mockReturnValue({ where: whereMock });
const joinMock1 = vi.fn().mockReturnValue({ innerJoin: joinMock2, where: whereMock });
return {
from: vi.fn().mockReturnValue({ innerJoin: joinMock1 }),
};
}
function noHashConflict() {
return selectChain([]);
}
function mockElection(
lockedBook: { primaryFileId: number | null; status: string; formatPriority: string[] },
files: Array<{ id: number; format: string | null; sizeBytes: number | null; mediaOverlayAvailable?: boolean }>,
) {
tx.select
.mockReset()
.mockReturnValueOnce(lockedBookSelectChain([lockedBook], forUpdate))
.mockReturnValueOnce(contentFilesSelectChain(files));
}
function mockUploadedFormat(format: string, filename = `book.${format}`) {
validator.sanitizeFilename.mockReturnValue(filename);
validator.validateFormat.mockReturnValue(format);
insertReturning.mockResolvedValue([
{
id: 55,
format,
role: 'content',
sizeBytes: 456,
absolutePath: `/library/Book Title/${filename}`,
createdAt: new Date('2025-01-01'),
durationSeconds: null,
},
]);
}
it('adds a file successfully to an existing book with a primary file', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(storage.streamToTemp).toHaveBeenCalled();
expect(storage.moveToPath).toHaveBeenCalledWith('/tmp/upload.bin', '/library/Book Title/book.epub');
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(forUpdate).toHaveBeenCalledWith('update', { of: books });
expect(tx.insert).toHaveBeenCalled();
expect(result.filename).toBe('book.epub');
expect(result.format).toBe('epub');
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('content');
});
it.each([
{
name: 'promotes a higher-priority EPUB over the existing M4B',
currentFormat: 'm4b',
uploadedFormat: 'epub',
formatPriority: ['epub', 'm4b'],
expectedPrimaryFileId: 55,
},
{
name: 'retains a higher-priority EPUB when an M4B is added',
currentFormat: 'epub',
uploadedFormat: 'm4b',
formatPriority: ['epub', 'm4b'],
expectedPrimaryFileId: 99,
},
{
name: 'honors a custom priority that places M4B above EPUB',
currentFormat: 'epub',
uploadedFormat: 'm4b',
formatPriority: ['m4b', 'epub'],
expectedPrimaryFileId: 55,
},
{
name: 'promotes a listed format over an unlisted current format',
currentFormat: 'm4b',
uploadedFormat: 'epub',
formatPriority: ['epub'],
expectedPrimaryFileId: 55,
},
{
name: 'retains a listed current format over an unlisted upload',
currentFormat: 'epub',
uploadedFormat: 'm4b',
formatPriority: ['epub'],
expectedPrimaryFileId: 99,
},
{
name: 'retains the current primary when both formats are unlisted',
currentFormat: 'm4b',
uploadedFormat: 'epub',
formatPriority: [],
expectedPrimaryFileId: 99,
},
{
name: 'retains the current primary when another file of the same audio format is added',
currentFormat: 'mp3',
uploadedFormat: 'mp3',
formatPriority: ['epub', 'mp3'],
expectedPrimaryFileId: 99,
},
])('$name', async ({ currentFormat, uploadedFormat, formatPriority, expectedPrimaryFileId }) => {
mockUploadedFormat(uploadedFormat);
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'present', formatPriority }, [
{ id: 55, format: uploadedFormat, sizeBytes: 456 },
{ id: 99, format: currentFormat, sizeBytes: 1000 },
]);
const result = await service.addFileToBook(10, `book.${uploadedFormat}`, {} as any, user);
expect(insertValues).toHaveBeenCalledWith(expect.objectContaining({ format: uploadedFormat, role: 'content' }));
if (expectedPrimaryFileId === 55) {
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55 }));
expect(result.role).toBe('primary');
} else {
expect(tx.update).not.toHaveBeenCalled();
expect(result.role).toBe('content');
}
});
it('elects an existing higher-priority file when the book has no primary', async () => {
mockUploadedFormat('m4b');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null })])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: null, status: 'present', formatPriority: ['epub', 'm4b'] }, [
{ id: 10, format: 'epub', sizeBytes: 1000 },
{ id: 55, format: 'm4b', sizeBytes: 456 },
]);
const result = await service.addFileToBook(10, 'book.m4b', {} as any, user);
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 10 }));
expect(result.role).toBe('content');
});
it('promotes an uploaded read-aloud EPUB over an automatically selected plain EPUB', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'present', formatPriority: ['epub'] }, [
{ id: 55, format: 'epub', sizeBytes: 456, mediaOverlayAvailable: true },
{ id: 99, format: 'epub', sizeBytes: 1000, mediaOverlayAvailable: false },
]);
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55 }));
expect(result.role).toBe('primary');
});
it('repairs a stale primary reference using the highest-priority eligible file', async () => {
mockUploadedFormat('m4b');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: 777 })])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 777, status: 'present', formatPriority: ['pdf', 'm4b'] }, [
{ id: 10, format: 'pdf', sizeBytes: 1000 },
{ id: 55, format: 'm4b', sizeBytes: 456 },
]);
const result = await service.addFileToBook(10, 'book.m4b', {} as any, user);
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 10 }));
expect(result.role).toBe('content');
});
it('ignores a zero-byte higher-priority legacy file during election', async () => {
mockUploadedFormat('pdf');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'present', formatPriority: ['epub', 'pdf', 'm4b'] }, [
{ id: 10, format: 'epub', sizeBytes: 0 },
{ id: 55, format: 'pdf', sizeBytes: 456 },
{ id: 99, format: 'm4b', sizeBytes: 1000 },
]);
const result = await service.addFileToBook(10, 'book.pdf', {} as any, user);
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55 }));
expect(result.role).toBe('primary');
});
it('updates status and primary file together when a higher-priority format restores a missing book', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ status: 'missing' })])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'missing', formatPriority: ['epub', 'm4b'] }, [
{ id: 55, format: 'epub', sizeBytes: 456 },
{ id: 99, format: 'm4b', sizeBytes: 1000 },
]);
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55, status: 'present' }));
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('primary');
});
it('kicks off per-file audio duration extraction for an added audio file', async () => {
validator.sanitizeFilename.mockReturnValue('chapter-02.mp3');
validator.validateFormat.mockReturnValue('mp3');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
await service.addFileToBook(10, 'chapter-02.mp3', {} as any, user);
expect(processor.extractAudioDurationAsync).toHaveBeenCalledWith(10, '/library/Book Title/chapter-02.mp3', 'mp3');
expect(processor.reconcileCoversAsync).toHaveBeenCalledWith([10]);
});
it('places an added track by its name before the transaction commits, instead of after every other track', async () => {
validator.sanitizeFilename.mockReturnValue('chapter-02.mp3');
validator.validateFormat.mockReturnValue('mp3');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
await service.addFileToBook(10, 'chapter-02.mp3', {} as any, user);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledTimes(1);
expect(mockRealignAudioTrackOrder).toHaveBeenCalledWith(tx, 10);
expect(mockRealignAudioTrackOrder.mock.invocationCallOrder[0]).toBeGreaterThan(insertValues.mock.invocationCallOrder[0]!);
});
it('leaves track order alone when the added file is not audio', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(mockRealignAudioTrackOrder).not.toHaveBeenCalled();
});
it('schedules audio chapter extraction when an M4B is added to an EPUB-primary book', async () => {
mockUploadedFormat('m4b');
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'present', formatPriority: ['epub', 'm4b'] }, [
{ id: 99, format: 'epub', sizeBytes: 1000 },
{ id: 55, format: 'm4b', sizeBytes: 456 },
]);
await service.addFileToBook(10, 'book.m4b', {} as any, user);
expect(processor.extractAudioDurationAsync).toHaveBeenCalledWith(10, expect.stringMatching(/book\.m4b$/), 'm4b');
expect(processor.extractAddedAudioChaptersAsync).toHaveBeenCalledWith(10, 'm4b');
});
it('delegates duration extraction to the processor regardless of format (processor gates on audio)', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(processor.extractAudioDurationAsync).toHaveBeenCalledWith(10, '/library/Book Title/book.epub', 'epub');
});
it('promotes new file to primary when book has no primary file', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null })])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: null, status: 'present', formatPriority: ['epub', 'm4b'] }, [{ id: 55, format: 'epub', sizeBytes: 456 }]);
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(tx.update).toHaveBeenCalled();
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55 }));
expect(updateWhere).toHaveBeenCalled();
expect(result.role).toBe('primary');
});
it('updates book status from missing to present and promotes primary when null', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: null, status: 'missing' })])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: null, status: 'missing', formatPriority: ['epub', 'm4b'] }, [{ id: 55, format: 'epub', sizeBytes: 456 }]);
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(tx.update).toHaveBeenCalled();
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ primaryFileId: 55, status: 'present' }));
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('primary');
});
it('updates only status when book is missing but already has a primary file', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ primaryFileId: 42, status: 'missing' })])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 42, status: 'missing', formatPriority: ['epub', 'm4b'] }, [
{ id: 42, format: 'epub', sizeBytes: 1000 },
{ id: 55, format: 'epub', sizeBytes: 456 },
]);
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(tx.update).toHaveBeenCalled();
expect(updateSet).toHaveBeenCalledWith(expect.objectContaining({ status: 'present' }));
expect(updateSet.mock.calls[0]?.[0]).not.toHaveProperty('primaryFileId');
expect(result.bookStatus).toBe('present');
expect(result.role).toBe('content');
});
it('does not call db.update when book is present and already has a primary', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(tx.update).not.toHaveBeenCalled();
});
it('throws NotFoundException when book does not exist', async () => {
db.select.mockReturnValueOnce(selectJoinChain([]));
await expect(service.addFileToBook(999, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(NotFoundException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('does not insert when the book is deleted while the file is being uploaded', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
tx.select.mockReset().mockReturnValueOnce(lockedBookSelectChain([], forUpdate));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(NotFoundException);
expect(forUpdate).toHaveBeenCalledWith('update', { of: books });
expect(tx.insert).not.toHaveBeenCalled();
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/Book Title/book.epub');
});
it('throws ForbiddenException when user has no library access', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ForbiddenException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException for book_per_file organization mode', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow({ organizationMode: 'book_per_file' })]));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException when format validation fails', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
validator.validateFormat.mockImplementation(() => {
throw new BadRequestException('Unsupported format');
});
await expect(service.addFileToBook(10, 'bad.xyz', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.streamToTemp).not.toHaveBeenCalled();
});
it('throws BadRequestException for empty file and cleans up temp', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()]));
storage.streamToTemp.mockResolvedValue({ tempPath: '/tmp/upload.bin', sizeBytes: 0 });
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(BadRequestException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
});
it('throws ConflictException for duplicate content hash and cleans up temp', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(selectChain([{ id: 77 }]));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('throws ConflictException when destination file already exists and cleans up temp', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockFsAccess.mockResolvedValue(undefined);
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toBeInstanceOf(ConflictException);
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('cleans up only temp when DB insert fails after move (file remains for scanner)', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
insertReturning.mockRejectedValue(new Error('DB error'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('DB error');
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/Book Title/book.epub');
});
it('does not dispatch processing when the primary election update fails', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockElection({ primaryFileId: 99, status: 'present', formatPriority: ['epub', 'm4b'] }, [
{ id: 55, format: 'epub', sizeBytes: 456 },
{ id: 99, format: 'm4b', sizeBytes: 1000 },
]);
updateWhere.mockRejectedValue(new Error('primary update failed'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('primary update failed');
expect(processor.extractAudioDurationAsync).not.toHaveBeenCalled();
expect(processor.extractAddedAudioChaptersAsync).not.toHaveBeenCalled();
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
expect(storage.cleanup).not.toHaveBeenCalledWith('/library/Book Title/book.epub');
});
it('cleans up only temp when move fails (destination not yet written)', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
storage.moveToPath.mockRejectedValue(new Error('disk full'));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('disk full');
expect(storage.cleanup).toHaveBeenCalledWith('/tmp/upload.bin');
});
it('propagates access errors other than ENOENT from destinationExists check', async () => {
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
mockFsAccess.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.addFileToBook(10, 'book.epub', {} as any, user)).rejects.toThrow('permission denied');
expect(storage.moveToPath).not.toHaveBeenCalled();
});
it('returns correct createdAt as ISO string', async () => {
const date = new Date('2025-06-01T12:00:00.000Z');
insertReturning.mockResolvedValue([
{
id: 55,
format: 'epub',
role: 'content',
sizeBytes: 456,
absolutePath: '/library/Book Title/book.epub',
createdAt: date,
durationSeconds: null,
},
]);
db.select.mockReturnValueOnce(selectJoinChain([makeBookRow()])).mockReturnValueOnce(noHashConflict());
const result = await service.addFileToBook(10, 'book.epub', {} as any, user);
expect(result.createdAt).toBe(date.toISOString());
});
});
describe('renameBookFiles', () => {
it('calls performRename with force=true', async () => {
const mockRenameService = { performRename: vi.fn().mockResolvedValue({ status: 'success', durationMs: 50 }) };
moduleRef.get.mockReturnValue(mockRenameService);
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
await expect(service.renameBookFiles(10, user)).resolves.toBeUndefined();
expect(mockRenameService.performRename).toHaveBeenCalledWith(10, user.id, true, false);
});
it('throws NotFoundException when book does not exist', async () => {
db.select.mockReturnValueOnce(selectChain([]));
await expect(service.renameBookFiles(999, user)).rejects.toBeInstanceOf(NotFoundException);
});
it('throws ForbiddenException when user has no library access', async () => {
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
libraryService.verifyUserAccess.mockRejectedValue(new ForbiddenException('No access'));
await expect(service.renameBookFiles(10, user)).rejects.toBeInstanceOf(ForbiddenException);
});
it('throws ServiceUnavailableException when rename service is unavailable', async () => {
moduleRef.get.mockReturnValue(null);
db.select.mockReturnValueOnce(selectChain([{ id: 10, libraryId: 1 }]));
await expect(service.renameBookFiles(10, user)).rejects.toThrow(ServiceUnavailableException);
});
});
});