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; const mockRealignAudioTrackOrder = realignAudioTrackOrder as MockedFunction; const mockLstat = lstat as MockedFunction; const mockReaddir = readdir as MockedFunction; const mockStat = stat as MockedFunction; const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction; const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction; const mockParseMobiFile = parseMobiFile as MockedFunction; const mockParsePdfFile = parsePdfFile as MockedFunction; const mockComputeFileHash = computeFileHash as MockedFunction; function selectChain(rows: unknown[]) { const whereResult: PromiseLike & { 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) { 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 = { '/': ['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 = {}) { 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); }); }); });