Files
bookorbit/server/src/modules/book/book.service.test.ts
T
neon 836186159a fix(email): keep attachment filenames in sync after rename
Keep relative file paths synchronized during manual renames and repair
stale rows during watcher and full-scan reconciliation. Derive email
attachment names from the file being streamed.

Fixes #1286
2026-09-06 14:26:29 +00:00

5261 lines
213 KiB
TypeScript

import { BadRequestException, ConflictException, ForbiddenException, Logger, NotFoundException } from '@nestjs/common';
import type { MockedFunction } from 'vitest';
import { access, readdir, rm, stat, rename } from 'fs/promises';
import type { RequestUser } from '../../common/types/request-user';
import { AUDIO_BOOK_FILE_WRITE_FIELDS, MetadataProviderKey, Permission, type BookQuery, type MetadataFetchDiagnostics } from '@bookorbit/types';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractAudioMetadata } from '../metadata/extractors/audio.extractor';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseFb2File } from '../metadata/lib/fb2-parser';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
import { ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
import { BulkEditFieldsDto } from './dto/bulk-edit-metadata.dto';
import { BookQueryBuilder } from './book-query-builder.service';
import { BookService } from './book.service';
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
vi.mock('fs/promises', async () => {
const actual = await vi.importActual('fs/promises');
return {
...actual,
access: vi.fn(),
readdir: vi.fn(),
rm: vi.fn(),
rename: vi.fn(),
stat: vi.fn(),
};
});
vi.mock('../metadata/lib/epub', () => ({
extractEpubMetadata: vi.fn(),
}));
vi.mock('../metadata/extractors/audio.extractor', () => ({
extractAudioMetadata: vi.fn(),
}));
vi.mock('../metadata/lib/cbz-metadata', () => ({
extractCbzMetadata: vi.fn(),
extractCbrMetadata: vi.fn(),
extractCb7Metadata: vi.fn(),
}));
vi.mock('../metadata/lib/fb2-parser', () => ({
parseFb2File: vi.fn(),
}));
vi.mock('../metadata/lib/mobi-parser', () => ({
parseMobiFile: vi.fn(),
}));
vi.mock('../metadata/lib/pdf-parser', () => ({
parsePdfFile: vi.fn(),
}));
const mockAccess = access as MockedFunction<typeof access>;
const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockRm = rm as MockedFunction<typeof rm>;
const mockStat = stat as MockedFunction<typeof stat>;
const mockExtractEpubMetadata = extractEpubMetadata as MockedFunction<typeof extractEpubMetadata>;
const mockExtractAudioMetadata = extractAudioMetadata as MockedFunction<typeof extractAudioMetadata>;
const mockExtractCbzMetadata = extractCbzMetadata as MockedFunction<typeof extractCbzMetadata>;
const mockExtractCbrMetadata = extractCbrMetadata as MockedFunction<typeof extractCbrMetadata>;
const mockExtractCb7Metadata = extractCb7Metadata as MockedFunction<typeof extractCb7Metadata>;
const mockParseFb2File = parseFb2File as MockedFunction<typeof parseFb2File>;
const mockParseMobiFile = parseMobiFile as MockedFunction<typeof parseMobiFile>;
const mockParsePdfFile = parsePdfFile as MockedFunction<typeof parsePdfFile>;
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
id: 1,
username: 'tester',
name: 'Tester',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
contentFilters: EMPTY_CONTENT_FILTER_RULES,
};
}
function makeMetadataFetchDiagnostics(overrides: Partial<MetadataFetchDiagnostics> = {}): MetadataFetchDiagnostics {
return {
reason: null,
activeProviders: [],
fieldRuleProviders: [],
disabledFieldRuleProviders: [],
enabledUnreferencedProviders: [],
throttledProviders: [],
candidateProviders: [],
candidateCount: 0,
resolvedFieldCount: 0,
...overrides,
};
}
function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
const bookRepo = {
findCards: vi.fn(),
countWhere: vi.fn(),
findPatternMetadataByBookIds: vi.fn(),
findLibraryIdsByBookIds: vi.fn(),
findDeletionAuditBooksByIds: vi.fn(),
findPrimaryFilesByBookIds: vi.fn(),
findAllFilesByBookIds: vi.fn(),
findTagsByBookIds: vi.fn(),
findAuthorsByBookIds: vi.fn(),
findGenresByBookIds: vi.fn(),
findNarratorsByBookIds: vi.fn(),
findPrimaryFile: vi.fn(),
findById: vi.fn(),
findRatingByBookAndUser: vi.fn().mockResolvedValue(null),
findCollectionsByBookId: vi.fn(),
findKoboReadingState: vi.fn(),
findKoboSnapshotStates: vi.fn(),
findKoboSyncCollectionNamesForBook: vi.fn(),
findFileById: vi.fn(),
findLibraryIdByBookId: vi.fn(),
findProgress: vi.fn(),
findProgressByBook: vi.fn(),
upsertProgress: vi.fn(),
syncKoboReadingStateFromProgress: vi.fn(),
isKoboTwoWayProgressSyncEnabled: vi.fn().mockResolvedValue(false),
clearFileProgress: vi.fn(),
findAudioProgress: vi.fn(),
upsertAudioProgress: vi.fn(),
bulkSetRating: vi.fn(),
bulkUpdateMetadataFields: vi.fn(),
updateMetadataFields: vi.fn(),
setHardcoverEditionIdIfEmpty: vi.fn(),
updateAddedAt: vi.fn(),
replaceCommunityRatings: vi.fn(),
withTransaction: vi.fn(),
deleteByIds: vi.fn(),
deleteByIdsAndInvalidateScanState: vi.fn(),
findAllIds: vi.fn(),
findIdsByWhere: vi.fn(),
findCardsCollapsed: vi.fn(),
findJumpBuckets: vi.fn(),
findJumpBucketsCollapsed: vi.fn(),
findTemporalJumpBuckets: vi.fn(),
findTemporalJumpBucketsCollapsed: vi.fn(),
checkBookPassesContentFilters: vi.fn().mockResolvedValue(true),
};
const libraryService = {
verifyUserAccess: vi.fn().mockResolvedValue(undefined),
findAll: vi.fn().mockResolvedValue([]),
findOne: vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 }),
};
const queryBuilder = {
buildWhere: vi.fn(),
buildOrderBy: vi.fn(),
};
const metadataService = {
replaceAuthors: vi.fn().mockResolvedValue([]),
replaceGenres: vi.fn().mockResolvedValue(undefined),
replaceTags: vi.fn().mockResolvedValue(undefined),
emitAuthorsReplaced: vi.fn(),
downloadAndSaveCover: vi.fn().mockResolvedValue(undefined),
refreshCoverForBook: vi.fn(),
};
const pipeline = {
run: vi.fn(),
runWithSources: vi.fn(),
};
const config = {
get: vi.fn().mockImplementation((key: string) => (key === 'storage.appDataPath' ? '/tmp/books' : undefined)),
};
const appSettings = {
getDownloadPattern: vi.fn().mockResolvedValue('{originalFilename}'),
};
const scoreService = {
calculateAndSave: vi.fn().mockResolvedValue(undefined),
calculateAndSaveMany: vi.fn(),
};
scoreService.calculateAndSaveMany.mockImplementation((bookIds: number[]) =>
Promise.all(bookIds.map((bookId) => scoreService.calculateAndSave(bookId))).then(() => undefined),
);
const embedder = {
embedBook: vi.fn().mockResolvedValue(undefined),
};
const fileWriteService = {
scheduleWrite: vi.fn(),
cancelPendingWrite: vi.fn(),
writeToFile: vi.fn(),
findLibraryWriteSettingsForBook: vi.fn().mockResolvedValue({ fileWriteEnabled: false, fileRenameEnabled: false }),
resolveBookFileWriteStatus: vi.fn().mockReturnValue({ enabled: false, reason: 'library_disabled', writableFormats: [], writableFields: [] }),
};
const fileRenameService = {
scheduleRename: vi.fn(),
cancelPendingRename: vi.fn(),
performRename: vi.fn(),
};
const achievementEvents = {
emit: vi.fn(),
};
const narratorService = {
replaceForBook: vi.fn().mockResolvedValue(undefined),
};
const comicMetadataService = {
upsert: vi.fn().mockResolvedValue(undefined),
findByBookId: vi.fn().mockResolvedValue(null),
};
const customMetadataService = {
getActiveFieldTypes: vi.fn().mockResolvedValue(new Map()),
getBookValues: vi.fn().mockResolvedValue([]),
getCardValues: vi.fn().mockResolvedValue([]),
getExportValues: vi.fn().mockResolvedValue(new Map()),
parseFileValuesForBook: vi.fn().mockResolvedValue([]),
updateBookValues: vi.fn().mockResolvedValue(undefined),
};
const bookMetadataLockService = {
normalizeLockedFields: vi.fn().mockImplementation((fields: string[] | null | undefined) => fields ?? []),
isFieldLocked: vi.fn().mockResolvedValue(false),
assertManualUpdateAllowed: vi.fn().mockResolvedValue(undefined),
filterResolvedMetadata: vi.fn().mockImplementation((_bookId: number, resolved: unknown, providerIds: unknown) =>
Promise.resolve({
resolved,
providerIds,
skippedFields: [],
}),
),
assertFieldsUnlocked: vi.fn().mockResolvedValue(undefined),
getCoverLockedBookIds: vi.fn().mockResolvedValue(new Set()),
getBookIdsWithLockedField: vi.fn().mockResolvedValue(new Set()),
getLockedFieldsMap: vi.fn().mockResolvedValue(new Map()),
replaceLockedFields: vi.fn().mockResolvedValue([]),
};
const userBookStatusService = {
autoUpdate: vi.fn().mockResolvedValue(undefined),
setManual: vi.fn().mockResolvedValue(undefined),
updateManual: vi.fn().mockResolvedValue({
status: 'unread',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-01-01T00:00:00.000Z',
}),
findOne: vi.fn().mockResolvedValue(null),
findByBookIds: vi.fn().mockResolvedValue(new Map()),
};
const userBookNoteService = {
findOne: vi.fn().mockResolvedValue(null),
setNote: vi.fn().mockResolvedValue({ note: null, updatedAt: '2026-01-01T00:00:00.000Z' }),
};
bookRepo.withTransaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => callback({}));
const service = new BookService(
bookRepo as never,
libraryService as never,
queryBuilder as never,
metadataService as never,
scoreService as never,
pipeline as never,
config as never,
appSettings as never,
userBookStatusService as never,
userBookNoteService as never,
narratorService as never,
comicMetadataService as never,
customMetadataService as never,
(overrides.bookMetadataLockService ?? bookMetadataLockService) as never,
embedder as never,
fileWriteService as never,
fileRenameService as never,
achievementEvents as never,
);
return {
service,
bookRepo,
libraryService,
queryBuilder,
metadataService,
scoreService,
pipeline,
config,
appSettings,
userBookStatusService,
userBookNoteService,
embedder,
fileWriteService,
fileRenameService,
achievementEvents,
narratorService,
comicMetadataService,
customMetadataService,
bookMetadataLockService,
};
}
function metaRow(bookId: number, fields?: Partial<{ title: string | null; authors: string[] }>) {
return {
bookId,
title: fields?.title ?? null,
subtitle: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
isbn13: null,
authors: fields?.authors ?? [],
};
}
function makeBookCard(id: number, overrides?: Record<string, unknown>) {
return {
id,
status: 'present',
title: `Book ${id}`,
subtitle: null,
authors: ['Author'],
seriesName: null,
seriesIndex: null,
files: [{ id: id * 10, format: 'epub', role: 'primary', sizeBytes: 123 }],
publishedYear: 2020,
language: 'en',
genres: ['Sci-Fi'],
tags: ['tag1'],
rating: 4,
readingProgress: 75,
readStatus: {
status: 'reading',
source: 'manual',
startedAt: '2026-05-01T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-05-07T00:00:00.000Z',
},
addedAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-05-01T00:00:00.000Z',
metadataScore: 88,
hasCover: true,
hasMetadataLocks: false,
lockedFields: [],
publisher: 'Pub',
pageCount: 320,
isbn13: '9780000000000',
hardcoverId: 'hardcover-book-slug',
hardcoverEditionId: '8941973',
narrators: [],
...(overrides ?? {}),
};
}
describe('BookService', () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccess.mockReset();
mockReaddir.mockReset();
mockRm.mockReset();
mockStat.mockReset();
mockExtractEpubMetadata.mockReset();
mockExtractAudioMetadata.mockReset();
mockExtractCbzMetadata.mockReset();
mockExtractCbrMetadata.mockReset();
mockExtractCb7Metadata.mockReset();
mockParseFb2File.mockReset();
mockParseMobiFile.mockReset();
mockParsePdfFile.mockReset();
});
describe('download naming', () => {
it('resolves download filename from pattern and metadata', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('<{authors:first} - >{title}');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Neuromancer', authors: ['William Gibson'] })]);
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/original-name.epub',
format: 'epub',
});
expect(filename).toBe('William Gibson - Neuromancer.epub');
});
it('falls back to sanitized original filename when pattern resolution fails', async () => {
const { service, appSettings } = makeService();
appSettings.getDownloadPattern.mockRejectedValue(new Error('settings unavailable'));
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/bad:name?.epub',
format: 'epub',
});
expect(filename).toBe('bad_name_.epub');
});
it('prefers file extension from path over unknown format', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('{title}.{extension}');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Dune' })]);
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/dune.PDF',
format: 'unknown',
});
expect(filename).toBe('Dune.pdf');
});
it('preserves numeric stem suffix and still appends real extension', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Lost in the Cañon' })]);
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/Lost in the Cañon.37466.epub',
format: 'epub',
});
expect(filename).toBe('Lost in the Cañon.37466.epub');
});
it('enforces actual file extension when pattern specifies a different extension', async () => {
const { service, appSettings, bookRepo } = makeService();
appSettings.getDownloadPattern.mockResolvedValue('{title}.pdf');
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Dune' })]);
const filename = await service.resolveDownloadFilename({
bookId: 10,
absolutePath: '/books/dune.epub',
format: 'epub',
});
expect(filename).toBe('Dune.pdf.epub');
});
});
describe('export files', () => {
it('throws when export is requested with no books', async () => {
const { service } = makeService();
await expect(service.getExportFiles([], makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('applies pattern to export zip paths and de-duplicates collisions', async () => {
const { service, appSettings, bookRepo, libraryService } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 77 },
{ id: 2, libraryId: 77 },
]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 100 },
{ bookId: 2, absolutePath: '/books/b.epub', format: 'epub', sizeBytes: 200 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Duplicate' }), metaRow(2, { title: 'Duplicate' })]);
const plan = await service.getExportFiles([1, 2], user, 'primary');
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 77, false);
expect(plan.projectedBytes).toBe(300);
expect(plan.files).toEqual([
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub', sizeBytes: 100 },
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub', sizeBytes: 200 },
]);
expect(plan.archiveFilename).toBe('books.zip');
});
it('uses the download pattern for single-book export archive filenames', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{authors:first} - {title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/track-01.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/track-02.mp3', format: 'mp3', sizeBytes: 6, sortOrder: 1 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune', authors: ['Frank Herbert'] })]);
const plan = await service.getExportFiles([1], user, 'audio');
expect(plan.archiveFilename).toBe('Frank Herbert - Dune.zip');
});
it('uses the book title as originalFilename for single-book audiobook archive filenames', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/CH01 THE DARK LORD ASCENDING.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/CH02 IN MEMORIAM.mp3', format: 'mp3', sizeBytes: 6, sortOrder: 1 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Harry Potter and the Deathly Hallows' })]);
const plan = await service.getExportFiles([1], user, 'audio');
expect(plan.archiveFilename).toBe('Harry Potter and the Deathly Hallows.zip');
});
it('falls back to the original stem when a single-book archive pattern cannot resolve', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{series}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/source.epub', format: 'epub', sizeBytes: 100 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Standalone' })]);
const plan = await service.getExportFiles([1], user, 'primary');
expect(plan.archiveFilename).toBe('source.zip');
});
it('sanitizes unsafe path segments in generated zip paths', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('../{title}/..//bad:name');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/source.epub', format: 'epub', sizeBytes: 100 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: '..' })]);
const plan = await service.getExportFiles([1], user, 'primary');
const [file] = plan.files;
expect(file.zipPath).toBe('download/download/download/bad_name.epub');
});
it('uses all-files query when allFormats is true', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 1, sortOrder: 0 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'One' })]);
await service.getExportFiles([1], user, 'all');
expect(bookRepo.findAllFilesByBookIds).toHaveBeenCalledWith([1]);
expect(bookRepo.findPrimaryFilesByBookIds).not.toHaveBeenCalled();
});
it('uses audio-only export scope when requested', async () => {
const { service, appSettings, bookRepo } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/a.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 10, sortOrder: 1 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'One' })]);
const plan = await service.getExportFiles([1], user, 'audio');
expect(plan.files).toHaveLength(1);
expect(plan.files[0]?.absolutePath).toBe('/books/a.mp3');
});
it('throws when selected book ids include missing records', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
await expect(service.getExportFiles([1, 2], makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('throws when selected books have no exportable files', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([]);
await expect(service.getExportFiles([1], makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('throws when selected books exceed configured limit', async () => {
const { service } = makeService();
const tooManyBookIds = Array.from({ length: 251 }, (_, i) => i + 1);
await expect(service.getExportFiles(tooManyBookIds, makeUser(), 'primary')).rejects.toThrow(BadRequestException);
});
it('throws when selected export files exceed configured limit', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const fileRows = Array.from({ length: 2001 }, (_, i) => ({
bookId: 1,
absolutePath: `/books/${i}.epub`,
format: 'epub',
sizeBytes: 1,
sortOrder: i,
}));
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue(fileRows);
await expect(service.getExportFiles([1], user, 'all')).rejects.toThrow(BadRequestException);
});
it('throws when projected export size exceeds limit', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub', sizeBytes: 9_000_000_000 }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Huge' })]);
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow(BadRequestException);
});
});
describe('metadata export', () => {
it('builds metadata export preflight for query selection', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 12 });
libraryService.findAll.mockResolvedValue([{ id: 5, name: 'Main' }]);
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE' as never);
bookRepo.countWhere.mockResolvedValue(42);
const preflight = await service.getMetadataExportPreflight(
{
query: { libraryId: 5, q: 'dune', sort: [{ field: 'title', dir: 'asc' }] },
format: 'csv',
viewType: 'library',
} as never,
user,
);
expect(preflight.rowCount).toBe(42);
expect(preflight.format).toBe('csv');
expect(preflight.scope).toBe('all-matching');
expect(preflight.fileName).toContain('bookorbit-library-all-matching-');
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(undefined, {
accessibleLibraryIds: [5],
implicitLibraryId: 5,
userId: 12,
q: 'dune',
timeZone: 'UTC',
});
expect(bookRepo.countWhere).toHaveBeenCalledWith('WHERE_CLAUSE');
});
it('rejects metadata export preflight for inaccessible library query selections', async () => {
const { service, libraryService } = makeService();
const user = makeUser({ id: 99 });
libraryService.findAll.mockResolvedValue([{ id: 7, name: 'Allowed' }]);
await expect(
service.getMetadataExportPreflight(
{
query: { libraryId: 9, q: 'dune' },
format: 'json',
viewType: 'library',
} as never,
user,
),
).rejects.toThrow(ForbiddenException);
});
it('builds csv metadata export with context lines and stable machine keys', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 7 });
libraryService.findAll.mockResolvedValue([{ id: 5, name: 'Main Library' }]);
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE' as never);
bookRepo.countWhere.mockResolvedValue(1);
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 5 }]);
vi.spyOn(service, 'executeBooksQuery').mockResolvedValue({
items: [makeBookCard(1)],
total: 1,
page: 0,
size: 1,
});
const exported = await service.buildMetadataExport(
{
query: { libraryId: 5, sort: [{ field: 'title', dir: 'asc' }] },
format: 'csv',
viewType: 'library',
options: { includePersonalData: false, includeContextMeta: true, columnsMode: 'canonical' },
} as never,
user,
);
expect(exported.contentType).toBe('text/csv; charset=utf-8');
expect(exported.fileName).toContain('.csv');
expect(exported.preflight.rowCount).toBe(1);
expect(exported.content).toContain('\uFEFF');
expect(exported.content).toContain('# schemaVersion=1');
expect(exported.content).toContain('bookId,libraryId,libraryName,status,title');
expect(exported.content).toContain('isbn13,hardcoverId,hardcoverEditionId,genres');
expect(exported.content).toContain('9780000000000,hardcover-book-slug,8941973');
expect(exported.content).toContain('1,5,Main Library,present,Book 1');
});
it('builds json metadata export with visible-column projection', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 11 });
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 2, libraryId: 8 }]);
libraryService.findAll.mockResolvedValue([{ id: 8, name: 'Focus' }]);
vi.spyOn(service, 'executeBooksQuery').mockResolvedValue({
items: [makeBookCard(2)],
total: 1,
page: 0,
size: 1,
});
const exported = await service.buildMetadataExport(
{
bookIds: [2],
sort: [{ field: 'title', dir: 'asc' }],
format: 'json',
viewType: 'collection',
options: { columnsMode: 'visible', visibleColumns: ['title', 'authors', 'format'], includePersonalData: true },
} as never,
user,
);
expect(exported.contentType).toBe('application/json; charset=utf-8');
const parsed = JSON.parse(exported.content) as {
schemaVersion: number;
meta: { viewType: string; scope: string };
items: Array<Record<string, unknown>>;
};
expect(parsed.schemaVersion).toBe(1);
expect(parsed.meta.viewType).toBe('collection');
expect(parsed.meta.scope).toBe('selected');
expect(parsed.items[0]).toMatchObject({
bookId: 2,
libraryId: 8,
libraryName: 'Focus',
title: 'Book 2',
});
expect(parsed.items[0]).toHaveProperty('authors');
expect(parsed.items[0]).toHaveProperty('primaryFormat', 'epub');
expect(parsed.items[0]).toHaveProperty('formats');
expect(parsed.items[0]).not.toHaveProperty('status');
});
it('omits json context metadata when includeContextMeta is disabled', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 15 });
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 4, libraryId: 6 }]);
libraryService.findAll.mockResolvedValue([{ id: 6, name: 'Focus' }]);
vi.spyOn(service, 'executeBooksQuery').mockResolvedValue({
items: [makeBookCard(4)],
total: 1,
page: 0,
size: 1,
});
const exported = await service.buildMetadataExport(
{
bookIds: [4],
format: 'json',
viewType: 'smartScope',
options: { includeContextMeta: false, columnsMode: 'canonical' },
} as never,
user,
);
const parsed = JSON.parse(exported.content) as Record<string, unknown>;
expect(parsed).toHaveProperty('schemaVersion', 1);
expect(parsed).toHaveProperty('items');
expect(parsed).not.toHaveProperty('meta');
});
it('rejects metadata export preflight when projected payload estimate exceeds guardrail', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser();
libraryService.findAll.mockResolvedValue([{ id: 1, name: 'Main' }]);
queryBuilder.buildWhere.mockReturnValue('WHERE_CLAUSE' as never);
bookRepo.countWhere.mockResolvedValue(100000);
await expect(
service.getMetadataExportPreflight(
{
query: { libraryId: 1 },
format: 'json',
viewType: 'library',
options: { includeFilePaths: true, includePersonalData: true },
} as never,
user,
),
).rejects.toThrow(BadRequestException);
});
});
describe('export concurrency slots', () => {
it('enforces max concurrent exports per user', () => {
const { service } = makeService();
const releaseOne = service.acquireExportSlot(7);
const releaseTwo = service.acquireExportSlot(7);
expect(() => service.acquireExportSlot(7)).toThrow();
releaseOne();
const releaseThree = service.acquireExportSlot(7);
releaseTwo();
releaseThree();
});
});
describe('access + file/cover helpers', () => {
it('throws NotFoundException when verifying file access for missing file', async () => {
const { service, bookRepo } = makeService();
bookRepo.findFileById.mockResolvedValue(null);
await expect(service.verifyFileAccess(99, makeUser())).rejects.toThrow(NotFoundException);
});
it('returns cover path with custom cover preferred over extracted cover', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
mockReaddir.mockResolvedValue(['cover_extracted.jpg', 'cover_custom.png'] as never);
const result = await service.getCoverPath(9, makeUser());
expect(result).toBe('/tmp/books/covers/9/cover_custom.png');
});
it('returns null cover path when cover directory cannot be read', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
const missingError = Object.assign(new Error('missing'), { code: 'ENOENT' });
mockReaddir.mockRejectedValue(missingError);
await expect(service.getCoverPath(9, makeUser())).resolves.toBeNull();
});
it('throws when cover directory lookup fails for non-missing errors', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
mockReaddir.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.getCoverPath(9, makeUser())).rejects.toThrow('permission denied');
});
it('returns thumbnail path only when file is accessible', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
mockAccess.mockResolvedValue(undefined);
await expect(service.getThumbnailPath(9, makeUser())).resolves.toBe('/tmp/books/covers/9/thumbnail.jpg');
mockAccess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await expect(service.getThumbnailPath(9, makeUser())).resolves.toBeNull();
});
it('throws when thumbnail access fails for non-missing errors', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(5);
mockAccess.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.getThumbnailPath(9, makeUser())).rejects.toThrow('permission denied');
});
it('returns file info with unknown format fallback', async () => {
const { service, bookRepo } = makeService();
bookRepo.findFileById.mockResolvedValue({ id: 10, absolutePath: '/books/test.book', format: null, bookId: 1, libraryId: 7 });
mockStat.mockResolvedValue({ size: 1234 } as never);
const result = await service.getFileInfo(10, makeUser());
expect(result).toEqual({
path: '/books/test.book',
size: 1234,
format: 'unknown',
bookId: 1,
originalFilename: 'test.book',
});
});
it('throws NotFoundException when file exists in DB but is missing on disk', async () => {
const { service, bookRepo } = makeService();
bookRepo.findFileById.mockResolvedValue({ id: 10, absolutePath: '/books/missing.book', format: null, bookId: 1, libraryId: 7 });
mockStat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await expect(service.getFileInfo(10, makeUser())).rejects.toThrow(NotFoundException);
});
});
describe('metadata refresh + update', () => {
it('refreshMetadata preview returns resolved fields without mutating metadata', async () => {
const { service, bookRepo, libraryService, pipeline, metadataService } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: {
title: 'Old Title',
subtitle: null,
description: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
coverSource: 'extracted',
isbn13: '978123',
isbn10: null,
googleBooksId: 'g-id',
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: 'ol-id',
},
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'New Title' },
sources: {},
providerIds: {},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
});
const updateSpy = vi.spyOn(service, 'updateMetadata');
const result = await service.refreshMetadata(1, true, user);
expect(result).toEqual({
metadata: { title: 'New Title' },
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
});
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 7, false);
expect(pipeline.runWithSources).toHaveBeenCalledWith(
{
title: 'Old Title',
author: 'Author One',
isbn: '978123',
existingProviderIds: {
[MetadataProviderKey.GOOGLE]: 'g-id',
[MetadataProviderKey.OPEN_LIBRARY]: 'ol-id',
},
isAudiobook: false,
maxCandidatesPerProvider: 1,
},
{
title: 'Old Title',
subtitle: null,
description: null,
authors: ['Author One'],
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
communityRating: [],
seriesName: null,
seriesIndex: null,
genres: [],
cover: 'extracted',
duration: undefined,
abridged: undefined,
},
7,
);
expect(updateSpy).not.toHaveBeenCalled();
expect(metadataService.downloadAndSaveCover).not.toHaveBeenCalled();
});
it('refreshMetadata updates mapped fields and downloads cover when provided', async () => {
const { service, bookRepo, pipeline, metadataService } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'Resolved', authors: ['A'], genres: ['G'], coverUrl: 'https://img/c.jpg' },
sources: {},
providerIds: {},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
const getDetailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 1, title: 'Final' } as never);
const result = await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(1, { title: 'Resolved', authors: ['A'], genres: ['G'] }, user, { postSaveMode: 'schedule' });
expect(metadataService.downloadAndSaveCover).toHaveBeenCalledWith('https://img/c.jpg', 1);
expect(getDetailSpy).toHaveBeenCalledWith(1, user);
expect(result).toEqual({ id: 1, title: 'Final' });
});
it('refreshMetadata preview includes provider ids returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'Resolved' },
sources: {},
providerIds: {
[MetadataProviderKey.GOOGLE]: 'g-id',
[MetadataProviderKey.LIBROFM]: '9781234567890',
[MetadataProviderKey.OPEN_LIBRARY]: 'ol-id',
},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
});
const result = await service.refreshMetadata(1, true, user);
expect(result).toEqual({
metadata: {
title: 'Resolved',
googleBooksId: 'g-id',
librofmId: '9781234567890',
openLibraryId: 'ol-id',
},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 4 }),
});
});
it('refreshMetadata preview includes community rating as an atomic provider bundle', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: '2026-06-25T00:00:00.000Z' }],
},
sources: { communityRating: MetadataProviderKey.HARDCOVER },
providerIds: {},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
});
const result = await service.refreshMetadata(1, true, user);
expect(result).toEqual({
metadata: {
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: '2026-06-25T00:00:00.000Z' }],
},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 1 }),
});
});
it('refreshMetadata preview nests audiobook fields under audioMetadata', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
title: 'Resolved',
narrators: ['Narrator One'],
duration: 3600,
abridged: true,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
},
sources: {},
providerIds: {},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 5 }),
});
const result = await service.refreshMetadata(1, true, user);
expect(result).toEqual({
metadata: {
title: 'Resolved',
audioMetadata: {
narrators: ['Narrator One'],
durationSeconds: 3600,
abridged: true,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
},
},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 2 }),
});
expect((result.metadata as Record<string, unknown>).narrators).toBeUndefined();
expect((result.metadata as Record<string, unknown>).duration).toBeUndefined();
expect((result.metadata as Record<string, unknown>).abridged).toBeUndefined();
expect((result.metadata as Record<string, unknown>).chapters).toBeUndefined();
});
it('refreshMetadata preview includes series memberships', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Confessor', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Terry Goodkind', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
seriesName: 'Sword of Truth',
seriesIndex: 11,
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: 11 },
{ seriesName: 'Chainfire Trilogy', seriesIndex: 3 },
],
},
sources: {},
providerIds: {},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 3 }),
});
const result = await service.refreshMetadata(1, true, user);
expect(result).toEqual({
metadata: {
seriesName: 'Sword of Truth',
seriesIndex: 11,
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: 11 },
{ seriesName: 'Chainfire Trilogy', seriesIndex: 3 },
],
},
diagnostics: makeMetadataFetchDiagnostics({ resolvedFieldCount: 3 }),
});
});
it('refreshMetadata persists provider ids returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'Resolved' },
sources: {},
providerIds: {
[MetadataProviderKey.GOOGLE]: 'g-id',
[MetadataProviderKey.LIBROFM]: '9781234567890',
[MetadataProviderKey.OPEN_LIBRARY]: 'ol-id',
},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
title: 'Resolved',
googleBooksId: 'g-id',
librofmId: '9781234567890',
openLibraryId: 'ol-id',
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata persists Hardcover edition id returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: '9780756404741', isbn10: null, hardcoverEditionId: null },
},
authorRows: [{ id: 1, name: 'Patrick Rothfuss', sortName: null }],
genreRows: [],
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { hardcoverEditionId: '8941973' },
sources: {},
providerIds: {
[MetadataProviderKey.HARDCOVER]: 'the-name-of-the-wind',
},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
hardcoverEditionId: '8941973',
hardcoverId: 'the-name-of-the-wind',
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata pins the search to a previously chosen Hardcover edition', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: '9780756404741', isbn10: null, hardcoverEditionId: '8941973' },
},
authorRows: [{ id: 1, name: 'Patrick Rothfuss', sortName: null }],
genreRows: [],
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({ resolved: {}, sources: {}, providerIds: {} });
await service.refreshMetadata(1, true, user);
expect(pipeline.runWithSources).toHaveBeenCalledWith(expect.objectContaining({ hardcoverEditionId: '8941973' }), expect.any(Object), 7);
});
it('refreshMetadata persists provider-specific community rating rows', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
communityRatingRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: null }],
},
sources: { communityRating: MetadataProviderKey.HARDCOVER },
providerIds: {},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
communityRatings: [{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: null }],
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata persists series memberships returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Confessor', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Terry Goodkind', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
seriesName: 'Sword of Truth',
seriesIndex: 11,
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: 11 },
{ seriesName: 'Chainfire Trilogy', seriesIndex: 3 },
],
},
sources: {},
providerIds: {},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
seriesName: 'Sword of Truth',
seriesIndex: 11,
seriesMemberships: [
{ seriesName: 'Sword of Truth', seriesIndex: 11 },
{ seriesName: 'Chainfire Trilogy', seriesIndex: 3 },
],
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata persists audiobook fields under audioMetadata', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
title: 'Resolved',
narrators: ['Narrator One'],
duration: 3600,
abridged: false,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
},
sources: {},
providerIds: {},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
title: 'Resolved',
audioMetadata: {
narrators: ['Narrator One'],
durationSeconds: 3600,
abridged: false,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
},
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata persists comic metadata returned by pipeline', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: {
title: 'Resolved',
comicMetadata: {
issueNumber: '12',
volumeName: 'Arkham Asylum',
pencillers: ['Jock'],
},
},
sources: {},
providerIds: {},
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(
1,
{
title: 'Resolved',
comicMetadata: {
issueNumber: '12',
volumeName: 'Arkham Asylum',
pencillers: ['Jock'],
},
},
user,
{ postSaveMode: 'schedule' },
);
});
it('refreshMetadata skips locked automated fields and cover mutations', async () => {
const { service, bookRepo, pipeline, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: {
books: { id: 1, libraryId: 7 },
book_metadata: { title: 'Old', isbn13: null, isbn10: null },
},
authorRows: [{ id: 1, name: 'Author One', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockResolvedValue({
resolved: { title: 'Resolved', authors: ['A'], coverUrl: 'https://img/c.jpg', hardcoverEditionId: '8941973' },
sources: {},
providerIds: {
[MetadataProviderKey.GOOGLE]: 'g-id',
},
});
bookMetadataLockService.filterResolvedMetadata.mockResolvedValue({
resolved: { authors: ['A'] },
providerIds: {},
skippedFields: ['title', 'cover', 'googleBooksId', 'hardcoverEditionId'],
});
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({
book: { id: 1 },
write: null,
libraryAutoWriteEnabled: false,
} as never);
await service.refreshMetadata(1, false, user);
expect(updateSpy).toHaveBeenCalledWith(1, { authors: ['A'] }, user, { postSaveMode: 'schedule' });
expect(metadataService.downloadAndSaveCover).not.toHaveBeenCalled();
});
it('updateMetadata writes scalar fields, syncs enabled file write and rename, and triggers embedding', async () => {
const { service, bookRepo, metadataService, embedder, fileWriteService, fileRenameService } = makeService();
const user = makeUser();
const verifySpy = vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
const detailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
fileWriteService.findLibraryWriteSettingsForBook.mockResolvedValue({ fileWriteEnabled: true, fileRenameEnabled: true });
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: ['title'], durationMs: 12 });
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 8, oldPath: '/old', newPath: '/new' });
const result = await service.updateMetadata(
5,
{
title: null,
rating: 4,
authors: ['A1', 'A2'],
genres: ['Sci-Fi'],
tags: ['favorite'],
},
user,
{ postSaveMode: 'sync' },
);
expect(verifySpy).toHaveBeenCalledWith(5, user);
expect(bookRepo.withTransaction).toHaveBeenCalledTimes(1);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({
title: null,
updatedAt: expect.any(Date),
}),
expect.anything(),
);
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], 4, user.id);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
5,
[
{ name: 'A1', sortName: null },
{ name: 'A2', sortName: null },
],
{ executor: expect.anything(), emitEvent: false },
);
expect(metadataService.replaceGenres).toHaveBeenCalledWith(5, ['Sci-Fi'], { executor: expect.anything() });
expect(metadataService.replaceTags).toHaveBeenCalledWith(5, ['favorite'], { executor: expect.anything() });
expect(metadataService.emitAuthorsReplaced).toHaveBeenCalledWith(5, []);
expect(fileWriteService.cancelPendingWrite).toHaveBeenCalledWith(5);
expect(fileRenameService.cancelPendingRename).toHaveBeenCalledWith(5);
expect(fileWriteService.writeToFile).toHaveBeenCalledWith(5, 'sync', user.id, false, false, true);
expect(fileRenameService.performRename).toHaveBeenCalledWith(5, user.id, false, true);
expect(fileWriteService.scheduleWrite).not.toHaveBeenCalled();
expect(fileRenameService.scheduleRename).not.toHaveBeenCalled();
expect(embedder.embedBook).toHaveBeenCalledWith(5);
expect(detailSpy).toHaveBeenCalledWith(5, user);
expect(result).toEqual({
book: { id: 5 },
write: { status: 'success', fieldsWritten: ['title'], durationMs: 12 },
libraryAutoWriteEnabled: true,
});
});
it('updateAddedAt stores the selected date in the user timezone and returns updated detail', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ settings: { timezone: 'America/New_York' } });
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, addedAt: '2020-06-15T04:00:00.000Z' } as never);
await expect(service.updateAddedAt(5, { addedAt: '2020-06-15' }, user)).resolves.toEqual({
id: 5,
addedAt: '2020-06-15T04:00:00.000Z',
});
expect(bookRepo.updateAddedAt).toHaveBeenCalledWith(5, new Date('2020-06-15T04:00:00.000Z'));
expect(service.getDetail).toHaveBeenCalledWith(5, user);
});
it('updateAddedAt rejects invalid and future dates before persistence', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'));
await expect(service.updateAddedAt(5, { addedAt: '2026-02-30' }, makeUser())).rejects.toThrow('addedAt must be a valid date');
await expect(service.updateAddedAt(5, { addedAt: '2026-05-21' }, makeUser())).rejects.toThrow('addedAt cannot be in the future');
expect(bookRepo.updateAddedAt).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('updateMetadata replaces community rating rows', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(
5,
{
communityRatings: [
{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345 },
{ provider: MetadataProviderKey.AMAZON, rating: 4.8, ratingCount: 104451 },
],
},
user,
);
expect(bookRepo.replaceCommunityRatings).toHaveBeenCalledWith(
5,
[
{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345 },
{ provider: MetadataProviderKey.AMAZON, rating: 4.8, ratingCount: 104451 },
],
expect.anything(),
);
});
it('updateMetadata normalizes publisher and series text before persistence', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { publisher: ' Ace\t Books ', seriesName: ' Dune Chronicles ' }, user);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({
publisher: 'Ace Books',
seriesName: 'Dune Chronicles',
updatedAt: expect.any(Date),
}),
expect.anything(),
);
});
it('updateMetadata with empty communityRatings clears all existing ratings', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { communityRatings: [] }, user);
expect(bookRepo.replaceCommunityRatings).toHaveBeenCalledWith(5, [], expect.anything());
});
it('updateMetadata without communityRatings does not touch community rating rows', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { title: 'Some Title' }, user);
expect(bookRepo.replaceCommunityRatings).not.toHaveBeenCalled();
});
it('updateMetadata keeps the saved metadata response when sync file write settings lookup fails', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, title: 'Saved Title' } as never);
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
fileWriteService.findLibraryWriteSettingsForBook.mockRejectedValue(new Error('settings db unavailable'));
const result = await service.updateMetadata(5, { title: 'Saved Title' }, user, { postSaveMode: 'sync' });
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({
title: 'Saved Title',
updatedAt: expect.any(Date),
}),
expect.anything(),
);
expect(fileWriteService.cancelPendingWrite).toHaveBeenCalledWith(5);
expect(fileRenameService.cancelPendingRename).toHaveBeenCalledWith(5);
expect(fileWriteService.writeToFile).not.toHaveBeenCalled();
expect(fileRenameService.performRename).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.update_metadata_file_write_settings] [fail] bookId=5'));
expect(result).toEqual({
book: { id: 5, title: 'Saved Title' },
write: expect.objectContaining({
status: 'failed',
fieldsWritten: [],
reason: 'file write settings unavailable',
}),
libraryAutoWriteEnabled: false,
});
warnSpy.mockRestore();
});
it('updateMetadata skips sync rename when file write returns a failed result', async () => {
const { service, fileWriteService, fileRenameService } = makeService();
const user = makeUser();
const failedWrite = {
status: 'failed',
fieldsWritten: ['title', 'authors'],
durationMs: 2200,
reason: '1 of 14 file writes failed',
};
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, title: 'Renamed Title' } as never);
fileWriteService.findLibraryWriteSettingsForBook.mockResolvedValue({ fileWriteEnabled: true, fileRenameEnabled: true });
fileWriteService.writeToFile.mockResolvedValue(failedWrite);
const result = await service.updateMetadata(5, { title: 'Renamed Title', authors: ['A1'] }, user, { postSaveMode: 'sync' });
expect(fileWriteService.cancelPendingWrite).toHaveBeenCalledWith(5);
expect(fileRenameService.cancelPendingRename).toHaveBeenCalledWith(5);
expect(fileWriteService.writeToFile).toHaveBeenCalledWith(5, 'sync', user.id, false, false, true);
expect(fileRenameService.performRename).not.toHaveBeenCalled();
expect(fileWriteService.scheduleWrite).not.toHaveBeenCalled();
expect(fileRenameService.scheduleRename).not.toHaveBeenCalled();
expect(result).toEqual({
book: { id: 5, title: 'Renamed Title' },
write: failedWrite,
libraryAutoWriteEnabled: true,
});
});
it('updateMetadata skips sync rename when file write throws', async () => {
const { service, fileWriteService, fileRenameService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, title: 'Throwing Write' } as never);
fileWriteService.findLibraryWriteSettingsForBook.mockResolvedValue({ fileWriteEnabled: true, fileRenameEnabled: true });
fileWriteService.writeToFile.mockRejectedValue(new Error('ffmpeg exited'));
const result = await service.updateMetadata(5, { title: 'Throwing Write' }, user, { postSaveMode: 'sync' });
expect(fileWriteService.writeToFile).toHaveBeenCalledWith(5, 'sync', user.id, false, false, true);
expect(fileRenameService.performRename).not.toHaveBeenCalled();
expect(result).toEqual({
book: { id: 5, title: 'Throwing Write' },
write: {
status: 'failed',
fieldsWritten: [],
durationMs: 0,
reason: 'ffmpeg exited',
},
libraryAutoWriteEnabled: true,
});
});
it('updateMetadata still runs sync rename when file write is skipped without failure', async () => {
const { service, fileWriteService, fileRenameService } = makeService();
const user = makeUser();
const skippedWrite = {
status: 'skipped',
fieldsWritten: [],
durationMs: 0,
reason: 'format not supported',
};
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, title: 'Rename Only' } as never);
fileWriteService.findLibraryWriteSettingsForBook.mockResolvedValue({ fileWriteEnabled: true, fileRenameEnabled: true });
fileWriteService.writeToFile.mockResolvedValue(skippedWrite);
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 8, oldPath: '/old', newPath: '/new' });
const result = await service.updateMetadata(5, { title: 'Rename Only' }, user, { postSaveMode: 'sync' });
expect(fileWriteService.writeToFile).toHaveBeenCalledWith(5, 'sync', user.id, false, false, true);
expect(fileRenameService.performRename).toHaveBeenCalledWith(5, user.id, false, true);
expect(result).toEqual({
book: { id: 5, title: 'Rename Only' },
write: skippedWrite,
libraryAutoWriteEnabled: true,
});
});
it('updateMetadata rejects manual writes to locked fields', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
const error = new ConflictException('Metadata fields are locked: title');
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookMetadataLockService.assertManualUpdateAllowed.mockRejectedValue(error);
await expect(service.updateMetadata(5, { title: 'Locked Title' }, user)).rejects.toThrow(error);
expect(bookRepo.withTransaction).not.toHaveBeenCalled();
});
it('updateMetadataAndLocks persists edited metadata and final locks in one transaction', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
const tx = { id: 'tx-locks' };
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['goodreadsId'] } as never);
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['goodreadsId']);
const result = await service.updateMetadataAndLocks(
5,
{
metadata: { goodreadsId: 'manual-goodreads-id' },
lockedFields: ['goodreadsId'],
},
user,
);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({
goodreadsId: 'manual-goodreads-id',
updatedAt: expect.any(Date),
}),
tx,
);
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['goodreadsId'], tx);
expect(result).toEqual({ book: { id: 5, lockedFields: ['goodreadsId'] }, write: null, libraryAutoWriteEnabled: false });
});
// Regression for issue #328: a field that is locked BOTH before and after the request must still be
// written (the unlock -> edit -> re-lock flow). Uses the real lock service so a reintroduced guard fails.
it('updateMetadataAndLocks writes a field that stays locked across the request', async () => {
const lockRepo = {
findLockedFields: vi.fn().mockResolvedValue(['title']),
findLockedFieldsByBookIds: vi.fn().mockResolvedValue(new Map()),
replaceLockedFields: vi.fn().mockResolvedValue(undefined),
};
const lockService = new BookMetadataLockService(lockRepo as never);
const { service, bookRepo } = makeService({ bookMetadataLockService: lockService });
const user = makeUser();
const tx = { id: 'tx-328' };
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['title'] } as never);
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
const result = await service.updateMetadataAndLocks(5, { metadata: { title: 'New Title' }, lockedFields: ['title'] }, user);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(5, expect.objectContaining({ title: 'New Title', updatedAt: expect.any(Date) }), tx);
expect(lockRepo.replaceLockedFields).toHaveBeenCalledWith(5, ['title'], tx);
expect(result.book).toEqual({ id: 5, lockedFields: ['title'] });
});
it('updateMetadataAndLocks can persist locks without scheduling metadata side effects', async () => {
const { service, bookRepo, bookMetadataLockService, embedder, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['title'] } as never);
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['title']);
await service.updateMetadataAndLocks(5, { metadata: {}, lockedFields: ['title'] }, user);
expect(bookRepo.updateMetadataFields).not.toHaveBeenCalled();
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['title'], expect.anything());
expect(embedder.embedBook).not.toHaveBeenCalled();
expect(fileWriteService.scheduleWrite).not.toHaveBeenCalled();
expect(fileWriteService.writeToFile).not.toHaveBeenCalled();
expect(fileRenameService.scheduleRename).not.toHaveBeenCalled();
expect(fileRenameService.performRename).not.toHaveBeenCalled();
expect(scoreService.calculateAndSave).not.toHaveBeenCalled();
});
it('updateMetadata does not clear omitted scalar fields on transformed dto instances', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const dto = new UpdateBookMetadataDto();
dto.publisher = 'Allowed Publisher';
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, dto, user);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({
publisher: 'Allowed Publisher',
updatedAt: expect.any(Date),
}),
expect.anything(),
);
expect(bookRepo.updateMetadataFields).not.toHaveBeenCalledWith(
5,
expect.objectContaining({
title: null,
}),
expect.anything(),
);
});
it('updateMetadata applies single-field title patch without touching other fields', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
const dto = new UpdateBookMetadataDto();
dto.title = 'Inline Edit Title';
await service.updateMetadata(5, dto, user);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(5, expect.objectContaining({ title: 'Inline Edit Title' }), expect.anything());
expect(bookRepo.updateMetadataFields).not.toHaveBeenCalledWith(
5,
expect.objectContaining({ seriesName: expect.anything() }),
expect.anything(),
);
});
it('updateMetadata applies single-field seriesName and seriesIndex patch', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
const dto = new UpdateBookMetadataDto();
dto.seriesName = 'Dune Saga';
dto.seriesIndex = 2;
await service.updateMetadata(5, dto, user);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
5,
expect.objectContaining({ seriesName: 'Dune Saga', seriesIndex: 2 }),
expect.anything(),
);
});
it('updateMetadata applies single-field language patch', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
const dto = new UpdateBookMetadataDto();
dto.language = 'fr';
await service.updateMetadata(5, dto, user);
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(5, expect.objectContaining({ language: 'fr' }), expect.anything());
});
it('updateMetadata clears rating to null', async () => {
const { service, bookRepo, achievementEvents } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { rating: null }, user);
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], null, user.id);
expect(achievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [5],
rating: null,
});
});
it('updateMetadata emits rating changed event when rating is updated', async () => {
const { service, achievementEvents } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { rating: 4 }, user);
expect(achievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, {
userId: user.id,
bookIds: [5],
rating: 4,
});
});
it('updateMetadata emits hardcover edition changed event when hardcoverEditionId is set', async () => {
const { service, achievementEvents } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { hardcoverEditionId: '200' }, user);
expect(achievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, {
userId: user.id,
bookId: 5,
hardcoverEditionId: '200',
});
});
it('updateMetadata does not emit hardcover edition changed event when hardcoverEditionId is cleared', async () => {
const { service, achievementEvents } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { hardcoverEditionId: null }, user);
expect(achievementEvents.emit).not.toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, expect.anything());
});
it('setHardcoverEditionIdIfEmpty delegates to the repository', async () => {
const { service, bookRepo } = makeService();
bookRepo.setHardcoverEditionIdIfEmpty.mockResolvedValue(true);
await expect(service.setHardcoverEditionIdIfEmpty(5, '200')).resolves.toBe(true);
expect(bookRepo.setHardcoverEditionIdIfEmpty).toHaveBeenCalledWith(5, '200');
});
it('updateMetadata replaces genres array only', async () => {
const { service, metadataService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { genres: ['Fantasy', 'Adventure'] }, user);
expect(metadataService.replaceGenres).toHaveBeenCalledWith(5, ['Fantasy', 'Adventure'], expect.anything());
expect(metadataService.replaceAuthors).not.toHaveBeenCalled();
expect(metadataService.replaceTags).not.toHaveBeenCalled();
});
it('updateMetadata replaces tags array only', async () => {
const { service, metadataService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
await service.updateMetadata(5, { tags: ['favorite', 'to-read'] }, user);
expect(metadataService.replaceTags).toHaveBeenCalledWith(5, ['favorite', 'to-read'], expect.anything());
expect(metadataService.replaceAuthors).not.toHaveBeenCalled();
expect(metadataService.replaceGenres).not.toHaveBeenCalled();
});
it('updateMetadataLocks replaces lock state and returns updated detail', async () => {
const { service, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
const detailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5, lockedFields: ['title'] } as never);
bookMetadataLockService.replaceLockedFields.mockResolvedValue(['title']);
const result = await service.updateMetadataLocks(5, ['title', 'title'], user);
expect(bookMetadataLockService.replaceLockedFields).toHaveBeenCalledWith(5, ['title', 'title']);
expect(detailSpy).toHaveBeenCalledWith(5, user);
expect(result).toEqual({ id: 5, lockedFields: ['title'] });
});
});
describe('kobo and batch behavior', () => {
it('returns not-eligible kobo state when user lacks kobo_sync permission', async () => {
const { service } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
const result = await service.getKoboState(10, user);
expect(result).toEqual({
eligibleForKoboSync: false,
syncCollections: [],
readingState: null,
snapshots: [],
});
});
it('normalizes kobo provider payload and clamps progress', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ permissions: ['kobo_sync'] } as never);
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findKoboReadingState.mockResolvedValue({
currentBookmark: { ProgressPercent: 130 },
statusInfo: { Status: 'Reading' },
createdAtKobo: 'created',
lastModifiedKobo: 'updated',
priorityTimestamp: 'priority',
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
});
bookRepo.findKoboSnapshotStates.mockResolvedValue([
{
deviceId: 99,
deviceName: 'Libra',
snapshotId: 99,
snapshotUpdatedAt: new Date('2026-01-03T00:00:00.000Z'),
synced: true,
pendingDelete: false,
isNew: false,
removedByDevice: false,
fileHash: 'fhash',
metadataHash: 'mhash',
},
{
deviceId: 100,
deviceName: 'Elipsa',
snapshotId: 100,
snapshotUpdatedAt: new Date('2026-01-04T00:00:00.000Z'),
synced: false,
pendingDelete: true,
isNew: false,
removedByDevice: false,
fileHash: null,
metadataHash: null,
},
]);
bookRepo.findKoboSyncCollectionNamesForBook.mockResolvedValue(['Favorites']);
const result = await service.getKoboState(10, user);
expect(result.eligibleForKoboSync).toBe(true);
expect(result.readingState?.progressPercent).toBe(100);
expect(result.readingState?.status).toBe('Reading');
expect(result.snapshots).toEqual([
expect.objectContaining({ deviceId: 99, deviceName: 'Libra', snapshotId: 99, synced: true, inSnapshot: true }),
expect.objectContaining({ deviceId: 100, deviceName: 'Elipsa', snapshotId: 100, synced: false, pendingDelete: true, fileHash: null }),
]);
});
it('ignores source-level Kobo progress in book state summaries', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ permissions: ['kobo_sync'] } as never);
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findKoboReadingState.mockResolvedValue({
currentBookmark: { ContentSourceProgressPercent: 75 },
statusInfo: { Status: 'Reading' },
createdAtKobo: 'created',
lastModifiedKobo: 'updated',
priorityTimestamp: 'priority',
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
});
bookRepo.findKoboSnapshotStates.mockResolvedValue([]);
bookRepo.findKoboSyncCollectionNamesForBook.mockResolvedValue(['Favorites']);
const result = await service.getKoboState(10, user);
expect(result.readingState?.progressPercent).toBeNull();
});
it('bulkReExtractCover reports progress for every processed book file, including unchanged covers', async () => {
const { service, bookRepo, libraryService, metadataService } = makeService();
const user = makeUser();
const onProgress = vi.fn();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' },
{ bookId: 2, absolutePath: '/books/2.epub', format: 'epub' },
]);
metadataService.refreshCoverForBook.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const result = await service.bulkReExtractCover([1, 2], user, onProgress);
expect(result).toEqual({ processed: 2, updated: 1 });
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 7, false);
expect(onProgress).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenNthCalledWith(1, 1);
expect(onProgress).toHaveBeenNthCalledWith(2, 2);
});
it('stops bulk cover extraction when progress callback throws', async () => {
const { service, bookRepo, metadataService } = makeService();
const user = makeUser();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' },
{ bookId: 2, absolutePath: '/books/2.epub', format: 'epub' },
]);
metadataService.refreshCoverForBook.mockResolvedValue(true);
const onProgress = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('stream closed');
})
.mockImplementation(() => undefined);
const result = await service.bulkReExtractCover([1, 2], user, onProgress);
expect(result).toEqual({ processed: 1, updated: 1 });
expect(metadataService.refreshCoverForBook).toHaveBeenCalledTimes(1);
});
it('bulkReExtractCover skips locked cover mutations', async () => {
const { service, bookRepo, bookMetadataLockService, metadataService } = makeService();
const user = makeUser();
const onProgress = vi.fn();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' }]);
bookMetadataLockService.getCoverLockedBookIds.mockResolvedValue(new Set([1]));
await expect(service.bulkReExtractCover([1], user, onProgress)).resolves.toEqual({ processed: 0, updated: 0 });
expect(bookMetadataLockService.getCoverLockedBookIds).toHaveBeenCalledWith([1]);
expect(metadataService.refreshCoverForBook).not.toHaveBeenCalled();
expect(onProgress).not.toHaveBeenCalled();
});
it('stops bulk metadata refresh when cancelled', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
const onProgress = vi.fn();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
{ id: 3, libraryId: 7 },
]);
const result = await service.bulkRefreshMetadata([1, 2, 3], user, onProgress, {
isCancelled: () => refreshSpy.mock.calls.length > 0,
});
expect(result).toEqual({ processed: 1, failed: 0 });
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(onProgress).toHaveBeenCalledTimes(1);
});
it('stops bulk metadata refresh when progress callback throws', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
]);
const onProgress = vi
.fn()
.mockImplementationOnce(() => {
throw new Error('stream closed');
})
.mockImplementation(() => undefined);
const result = await service.bulkRefreshMetadata([1, 2], user, onProgress);
expect(result).toEqual({ processed: 1, failed: 0 });
expect(refreshSpy).toHaveBeenCalledTimes(1);
expect(onProgress).toHaveBeenCalledTimes(1);
});
it('bulkRefreshMetadata delegates each item to non-preview refreshMetadata', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const refreshSpy = vi.spyOn(service, 'refreshMetadata').mockResolvedValue({ id: 1 } as never);
const onProgress = vi.fn();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
]);
const result = await service.bulkRefreshMetadata([1, 2], user, onProgress);
expect(result).toEqual({ processed: 2, failed: 0 });
expect(refreshSpy).toHaveBeenCalledWith(1, false, user);
expect(refreshSpy).toHaveBeenCalledWith(2, false, user);
expect(onProgress).toHaveBeenCalledTimes(2);
});
it('deleteBooks verifies access, removes book files, and removes cover directories without failing on rm errors', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 3, libraryId: 7 },
{ id: 4, libraryId: 9 },
]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 3, absolutePath: '/tmp/library/book3.epub', format: 'epub' },
{ bookId: 4, absolutePath: '/tmp/library/book4.pdf', format: 'pdf' },
]);
bookRepo.findDeletionAuditBooksByIds.mockResolvedValue([
{ id: 3, title: 'Dune' },
{ id: 4, title: null },
]);
bookRepo.deleteByIdsAndInvalidateScanState.mockResolvedValue(undefined);
mockRm.mockRejectedValue(new Error('cannot delete'));
const result = await service.deleteBooks([3, 4], user);
expect(libraryService.verifyUserAccess).toHaveBeenCalledTimes(2);
expect(bookRepo.deleteByIdsAndInvalidateScanState).toHaveBeenCalledWith([3, 4]);
expect(mockRm).toHaveBeenCalledWith('/tmp/books/covers/3', { recursive: true, force: true });
expect(mockRm).toHaveBeenCalledWith('/tmp/books/covers/4', { recursive: true, force: true });
expect(mockRm).toHaveBeenCalledWith('/tmp/library/book3.epub', { force: true });
expect(mockRm).toHaveBeenCalledWith('/tmp/library/book4.pdf', { force: true });
expect(warnSpy).toHaveBeenCalled();
expect(result).toEqual({
total: 2,
books: [
{ id: 3, title: 'Dune' },
{ id: 4, title: null },
],
omitted: 0,
});
});
it('caps deletion audit book details while preserving the deletion count', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const bookIds = Array.from({ length: 30 }, (_, index) => index + 1);
bookRepo.findLibraryIdsByBookIds.mockResolvedValue(bookIds.map((id) => ({ id, libraryId: 7 })));
bookRepo.findDeletionAuditBooksByIds.mockImplementation((ids: number[]) =>
Promise.resolve([...ids].reverse().map((id) => ({ id, title: `Book ${id}` }))),
);
bookRepo.findAllFilesByBookIds.mockResolvedValue([]);
bookRepo.deleteByIdsAndInvalidateScanState.mockResolvedValue(undefined);
const result = await service.deleteBooks(bookIds, user);
expect(bookRepo.findDeletionAuditBooksByIds).toHaveBeenCalledWith(bookIds.slice(0, 25));
expect(result.total).toBe(30);
expect(result.books).toHaveLength(25);
expect(result.books[0]).toEqual({ id: 1, title: 'Book 1' });
expect(result.books[24]).toEqual({ id: 25, title: 'Book 25' });
expect(result.omitted).toBe(5);
});
it('deletes books through the scan-state-aware repository transaction', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 3, libraryId: 7 }]);
bookRepo.findDeletionAuditBooksByIds.mockResolvedValue([{ id: 3, title: 'Dune' }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([]);
bookRepo.deleteByIdsAndInvalidateScanState.mockResolvedValue(undefined);
await service.deleteBooks([3], user);
expect(bookRepo.deleteByIdsAndInvalidateScanState).toHaveBeenCalledWith([3]);
});
it('does not commit book deletion when scan state invalidation fails', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 3, libraryId: 7 }]);
bookRepo.findDeletionAuditBooksByIds.mockResolvedValue([{ id: 3, title: 'Dune' }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([]);
bookRepo.deleteByIdsAndInvalidateScanState.mockRejectedValue(new Error('db down'));
await expect(service.deleteBooks([3], user)).rejects.toThrow('db down');
expect(warnSpy).toHaveBeenCalled();
expect(mockRm).not.toHaveBeenCalled();
});
it('returns queued=0 when embed-all is already running', async () => {
const { service, bookRepo, embedder } = makeService();
let resolveEmbed: (() => void) | null = null;
bookRepo.findAllIds.mockResolvedValue([11]);
embedder.embedBook.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveEmbed = resolve;
}),
);
await expect(service.embedAll()).resolves.toEqual({ queued: 1 });
await expect(service.embedAll()).resolves.toEqual({ queued: 0 });
resolveEmbed?.();
await Promise.resolve();
});
});
// ── AUDIO PROGRESS ─────────────────────────────────────────────────────────
describe('getAudioProgress', () => {
it('returns latest audio progress from repo', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
const progressRow = { fileId: 5, positionSeconds: 1234, updatedAt: new Date().toISOString() };
bookRepo.findLibraryIdByBookId = vi.fn().mockResolvedValue(1);
bookRepo.findAudioProgress = vi.fn().mockResolvedValue(progressRow);
const result = await service.getAudioProgress(user.id, 10, user);
expect(result).toBe(progressRow);
expect(bookRepo.findAudioProgress).toHaveBeenCalledWith(user.id, 10);
});
it('throws NotFoundException when book does not exist', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
bookRepo.findLibraryIdByBookId = vi.fn().mockResolvedValue(null);
await expect(service.getAudioProgress(user.id, 99, user)).rejects.toThrow();
});
});
describe('getBookProgress', () => {
it('returns one row per file with defaults for missing progress', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findProgressByBook.mockResolvedValue([
{
fileId: 10,
cfi: null,
pageNumber: null,
percentage: null,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
{
fileId: 11,
cfi: 'epubcfi(/6/4)',
pageNumber: 12,
percentage: 45,
koboLocationSource: 'OEBPS/chapter.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 22,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
updatedAt: new Date('2026-01-04T00:00:00.000Z'),
},
]);
const result = await service.getBookProgress(user.id, 99, user);
expect(bookRepo.findProgressByBook).toHaveBeenCalledWith(user.id, 99);
expect(result).toEqual([
{
fileId: 10,
cfi: null,
pageNumber: null,
percentage: 0,
koboLocationSource: null,
koboLocationType: null,
koboLocationValue: null,
koboContentSourceProgressPercent: null,
koreaderProgress: null,
updatedAt: null,
},
{
fileId: 11,
cfi: 'epubcfi(/6/4)',
pageNumber: 12,
percentage: 45,
koboLocationSource: 'OEBPS/chapter.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 22,
koreaderProgress: '/body/DocFragment[2]/body/p[1]/text()[1].0',
updatedAt: new Date('2026-01-04T00:00:00.000Z'),
},
]);
});
});
describe('saveProgress - positionSeconds', () => {
it('passes positionSeconds from DTO to repo', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser();
bookRepo.findFileById.mockResolvedValue({ id: 7, bookId: 10, libraryId: 1, absolutePath: '/books/a.m4b', format: 'm4b' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
await service.saveProgress(user.id, 7, { percentage: 25, positionSeconds: 900 } as never, user);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 7, null, null, 25, 900, null, null, null, null, null);
});
it('passes null positionSeconds when not provided in DTO', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser();
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 3, markAsFinishedPercentComplete: 97 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null, null, null, null, null, null);
expect(libraryService.findOne).toHaveBeenCalledWith(2);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97);
});
it('does not fail progress save when auto status update fails', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 3, markAsFinishedPercentComplete: 97 });
userBookStatusService.autoUpdate.mockRejectedValueOnce(new Error('status update failed'));
await expect(service.saveProgress(user.id, 8, { percentage: 50 } as never, user)).resolves.toBeUndefined();
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null, null, null, null, null, null);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.progress_status_update] [fail] userId=1 bookId=11 libraryId=2'));
warnSpy.mockRestore();
});
it('mirrors EPUB percentage to Kobo state for users with Kobo sync permission', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(true);
bookRepo.syncKoboReadingStateFromProgress.mockResolvedValue(true);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveProgress(
user.id,
8,
{
percentage: 50,
cfi: 'epubcfi(/6/2)',
koboLocationSource: 'OEBPS/ch1.xhtml',
koboLocationType: 'KoboSpan',
koboLocationValue: 'kobo.25.1',
koboContentSourceProgressPercent: 25,
} as never,
user,
);
expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(user.id, 8, 50, 'OEBPS/ch1.xhtml', 'KoboSpan', 'kobo.25.1', 25);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 4, 90);
});
it('does not mirror EPUB percentage to Kobo state when two-way sync is disabled', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(false);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.isKoboTwoWayProgressSyncEnabled).toHaveBeenCalledWith(user.id);
expect(bookRepo.syncKoboReadingStateFromProgress).not.toHaveBeenCalled();
});
it('does not mirror EPUB percentage to Kobo state without Kobo sync permission', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser();
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.isKoboTwoWayProgressSyncEnabled).not.toHaveBeenCalled();
expect(bookRepo.syncKoboReadingStateFromProgress).not.toHaveBeenCalled();
});
});
describe('clearFileProgress', () => {
it('verifies file access and clears file-scoped progress rows', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ id: 12 });
vi.spyOn(service, 'verifyFileAccess').mockResolvedValue({
id: 88,
absolutePath: '/books/sample.epub',
format: 'epub',
bookId: 77,
libraryId: 5,
});
await service.clearFileProgress(user.id, 88, user);
expect(service.verifyFileAccess).toHaveBeenCalledWith(88, user);
expect(bookRepo.clearFileProgress).toHaveBeenCalledWith(user.id, 88);
});
});
describe('saveAudioProgress', () => {
it('writes audio progress when current file belongs to the target book', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
bookRepo.findFileById.mockResolvedValue({
id: 7,
absolutePath: '/books/audiobook-1.mp3',
format: 'mp3',
bookId: 10,
libraryId: 1,
});
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveAudioProgress(
user.id,
10,
{
percentage: 33,
currentFileId: 7,
positionSeconds: 120,
},
user,
);
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(user.id, 10, 7, 120, 33);
expect(libraryService.findOne).toHaveBeenCalledWith(1);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 10, 33, 4, 90);
});
it('throws BadRequestException when current file belongs to a different book', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
bookRepo.findFileById.mockResolvedValue({
id: 8,
absolutePath: '/books/audiobook-2.mp3',
format: 'mp3',
bookId: 99,
libraryId: 1,
});
libraryService.verifyUserAccess.mockResolvedValue(undefined);
await expect(
service.saveAudioProgress(
user.id,
10,
{
percentage: 40,
currentFileId: 8,
positionSeconds: 90,
},
user,
),
).rejects.toThrow(BadRequestException);
expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled();
});
it('propagates ForbiddenException when current file is in an inaccessible library', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
bookRepo.findFileById.mockResolvedValue({
id: 9,
absolutePath: '/books/secret.mp3',
format: 'mp3',
bookId: 10,
libraryId: 2,
});
libraryService.verifyUserAccess.mockImplementation((_userId: number, libraryId: number) => {
if (libraryId === 2) {
return Promise.reject(new ForbiddenException());
}
return Promise.resolve();
});
await expect(
service.saveAudioProgress(
user.id,
10,
{
percentage: 20,
currentFileId: 9,
positionSeconds: 44,
},
user,
),
).rejects.toThrow(ForbiddenException);
expect(bookRepo.upsertAudioProgress).not.toHaveBeenCalled();
});
});
describe('query, pagination, and read-state delegates', () => {
it('queryForLibrary verifies access and returns paged cards', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 42 });
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findCards.mockResolvedValue({
rows: [
{
id: 10,
status: 'present',
primaryFileId: 1,
folderPath: '/books/dune',
addedAt: new Date('2026-01-01T00:00:00.000Z'),
title: 'Dune',
seriesName: null,
seriesIndex: null,
publishedYear: null,
language: null,
rating: null,
},
],
authorRows: [{ bookId: 10, name: 'Frank Herbert' }],
fileRows: [{ bookId: 10, id: 1, format: 'epub', role: 'primary' }],
genreRows: [{ bookId: 10, name: 'Sci-Fi' }],
progressRows: [{ bookFileId: 1, percentage: 10 }],
statusRows: [],
total: 1,
});
const result = await service.queryForLibrary(user, 7, {
filter: null,
sort: [],
pagination: { page: 1, size: 5 },
} as never);
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false);
expect(bookRepo.findCards).toHaveBeenCalledWith({
where: 'WHERE',
orderBy: ['ORDER'],
limit: 5,
offset: 5,
userId: 42,
});
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.size).toBe(5);
expect(result.items).toHaveLength(1);
});
it('queryForLibrary routes to findCardsCollapsed when collapseSeries is true', async () => {
const { service, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 5 });
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
bookRepo.findCardsCollapsed.mockResolvedValue({
rows: [
{
id: 20,
status: 'present',
primaryFileId: null,
folderPath: '/books/series-rep',
addedAt: new Date('2024-01-01T00:00:00.000Z'),
title: 'First Book',
seriesName: 'The Arc',
seriesIndex: 1,
publishedYear: null,
language: null,
rating: null,
coverSource: null,
lockedFields: null,
bookCount: 3,
readCount: 1,
coverBookIds: [20],
seriesLatestAddedAt: new Date('2024-06-01T00:00:00.000Z'),
},
],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 1,
});
const result = await service.queryForLibrary(user, 2, {
filter: null,
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 20 },
collapseSeries: true,
} as never);
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
where: 'WHERE',
sort: [{ field: 'title', dir: 'asc' }],
limit: 20,
offset: 0,
userId: 5,
});
expect(bookRepo.findCards).not.toHaveBeenCalled();
expect(result.total).toBe(1);
expect(result.items[0]!.collapsedSeries).toBeDefined();
expect(result.items[0]!.collapsedSeries!.bookCount).toBe(3);
});
it('queryJumpBucketsForLibrary verifies access and delegates to findJumpBuckets', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 42 });
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({
buckets: [{ key: 'A', label: 'A', index: 0 }],
total: 12,
kind: 'letter',
granularity: null,
});
const result = await service.queryJumpBucketsForLibrary(user, 7, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
} as never);
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(42, 7, false);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith([{ field: 'title', dir: 'asc' }], 42, undefined, {});
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(
expect.objectContaining({ where: 'WHERE', field: 'title', kind: 'letter', userId: 42, maxBuckets: 24, orderBy: ['ORDER'] }),
);
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
expect(result).toEqual({ buckets: [{ key: 'A', label: 'A', index: 0 }], total: 12, kind: 'letter', granularity: null });
});
it('executeJumpBucketsQuery rejects ineligible sorts with BadRequestException', async () => {
const { service, bookRepo } = makeService();
await expect(
service.executeJumpBucketsQuery(1, undefined, {
sort: [{ field: 'rating', dir: 'desc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
} as never),
).rejects.toBeInstanceOf(BadRequestException);
expect(bookRepo.findJumpBuckets).not.toHaveBeenCalled();
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery routes letter, category, and temporal sorts to their bounded query paths', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
bookRepo.findTemporalJumpBuckets.mockResolvedValue({
buckets: [],
total: 0,
kind: 'temporal',
granularity: null,
});
for (const sort of [
[{ field: 'author', dir: 'desc' }],
[{ field: 'series', dir: 'asc' }],
[{ field: 'publisher', dir: 'desc' }],
[{ field: 'format', dir: 'asc' }],
[{ field: 'language', dir: 'desc' }],
[{ field: 'readStatus', dir: 'asc' }],
[{ field: 'publishedYear', dir: 'asc' }],
[{ field: 'title', dir: 'desc' }],
[],
]) {
await service.executeJumpBucketsQuery(1, undefined, { sort, pagination: { page: 0, size: 50 }, maxBuckets: 24 } as never);
}
expect(bookRepo.findJumpBuckets).toHaveBeenCalledTimes(8);
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'series', kind: 'letter', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'publisher', kind: 'letter', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'format', kind: 'category', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'language', kind: 'category', maxBuckets: 24 }));
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ field: 'readStatus', kind: 'category', userId: 1 }));
expect(bookRepo.findTemporalJumpBuckets).toHaveBeenCalledWith(
expect.objectContaining({ field: 'publishedYear', direction: 'asc', precision: 'year', maxBuckets: 24 }),
);
});
it('executeJumpBucketsQuery routes to the collapsed variant when collapseSeries is set', async () => {
const { service, bookRepo } = makeService();
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(
9,
'WHERE' as never,
{
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
} as never,
);
expect(bookRepo.findJumpBucketsCollapsed).toHaveBeenCalledWith(
expect.objectContaining({ where: 'WHERE', sort: [{ field: 'title', dir: 'asc' }], userId: 9 }),
);
expect(bookRepo.findJumpBuckets).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery ignores collapseSeries when the filter targets a series', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(9, undefined, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'series', operator: 'eq', value: 'Dune' }] },
} as never);
expect(bookRepo.findJumpBuckets).toHaveBeenCalled();
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery collapses when a series rule belongs to the containing scope', async () => {
const { service, bookRepo } = makeService();
const scopeFilter = {
type: 'group' as const,
join: 'OR' as const,
rules: [{ type: 'rule' as const, field: 'series' as const, operator: 'contains' as const, value: 'Batman' }],
};
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(
9,
'WHERE' as never,
{
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
filter: scopeFilter,
},
'UTC',
{ seriesSelectionFilter: undefined },
);
expect(bookRepo.findJumpBucketsCollapsed).toHaveBeenCalledWith(
expect.objectContaining({ where: 'WHERE', sort: [{ field: 'title', dir: 'asc' }], userId: 9 }),
);
expect(bookRepo.findJumpBuckets).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery still expands when a temporary series filter is supplied separately', async () => {
const { service, queryBuilder, bookRepo } = makeService();
const temporaryFilter = {
type: 'group' as const,
join: 'AND' as const,
rules: [{ type: 'rule' as const, field: 'series' as const, operator: 'eq' as const, value: 'Batman' }],
};
const effectiveFilter = {
type: 'group' as const,
join: 'AND' as const,
rules: [
{
type: 'group' as const,
join: 'AND' as const,
rules: [{ type: 'rule' as const, field: 'language' as const, operator: 'eq' as const, value: 'en' }],
},
temporaryFilter,
],
};
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findJumpBuckets.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(
9,
'WHERE' as never,
{
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
filter: effectiveFilter,
},
'UTC',
{ seriesSelectionFilter: temporaryFilter },
);
expect(bookRepo.findJumpBuckets).toHaveBeenCalledWith(expect.objectContaining({ where: 'WHERE', orderBy: ['ORDER'], userId: 9 }));
expect(bookRepo.findJumpBucketsCollapsed).not.toHaveBeenCalled();
});
it('executeJumpBucketsQuery keeps collapseSeries for a series presence filter', async () => {
const { service, bookRepo } = makeService();
bookRepo.findJumpBucketsCollapsed.mockResolvedValue({ buckets: [], total: 0, kind: 'letter', granularity: null });
await service.executeJumpBucketsQuery(9, undefined, {
sort: [{ field: 'title', dir: 'asc' }],
pagination: { page: 0, size: 50 },
maxBuckets: 24,
collapseSeries: true,
filter: { type: 'group', join: 'AND', rules: [{ type: 'rule', field: 'series', operator: 'isNotEmpty' }] },
} as never);
expect(bookRepo.findJumpBucketsCollapsed).toHaveBeenCalled();
expect(bookRepo.findJumpBuckets).not.toHaveBeenCalled();
});
it('queryForLibrary uses normal findCards when collapseSeries is false', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findCards.mockResolvedValue({
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 0,
});
await service.queryForLibrary(makeUser(), 1, {
filter: null,
sort: [],
pagination: { page: 0, size: 10 },
collapseSeries: false,
} as never);
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
expect(bookRepo.findCards).toHaveBeenCalled();
});
it('queryForLibrary auto-disables collapse when a series filter is active', async () => {
const { service, queryBuilder, bookRepo } = makeService();
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findCards.mockResolvedValue({
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 0,
});
const filterWithSeries = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'series', operator: 'contains', value: 'Dune' }],
};
await service.queryForLibrary(makeUser(), 1, {
filter: filterWithSeries,
sort: [],
pagination: { page: 0, size: 10 },
collapseSeries: true,
} as never);
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
expect(bookRepo.findCards).toHaveBeenCalled();
});
it('globalQuery routes to findCardsCollapsed when collapseSeries is true', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 3 });
libraryService.findAll.mockResolvedValue([{ id: 1 }]);
queryBuilder.buildWhere.mockReturnValue('GLOBAL_WHERE' as never);
bookRepo.findCardsCollapsed.mockResolvedValue({
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 0,
});
await service.globalQuery(user, {
filter: null,
sort: [],
pagination: { page: 0, size: 10 },
collapseSeries: true,
} as never);
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith(expect.objectContaining({ userId: 3 }));
expect(bookRepo.findCards).not.toHaveBeenCalled();
});
it('globalQuery auto-disables collapse when a series filter is active', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
libraryService.findAll.mockResolvedValue([{ id: 1 }]);
queryBuilder.buildWhere.mockReturnValue('WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['ORDER'] as never);
bookRepo.findCards.mockResolvedValue({
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 0,
});
const filterWithSeries = {
type: 'group',
join: 'AND',
rules: [{ type: 'rule', field: 'series', operator: 'equals', value: 'Mistborn' }],
};
await service.globalQuery(makeUser(), {
filter: filterWithSeries,
sort: [],
pagination: { page: 0, size: 10 },
collapseSeries: true,
} as never);
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
expect(bookRepo.findCards).toHaveBeenCalled();
});
it('globalQuery throws when pagination window exceeds configured limit', async () => {
const { service } = makeService();
await expect(
service.globalQuery(makeUser(), {
filter: null,
sort: [],
pagination: { page: 9_999_999, size: 9_999_999 },
} as never),
).rejects.toThrow(BadRequestException);
});
it('globalQuery uses accessible libraries and assembled cards', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
const user = makeUser({ id: 7 });
libraryService.findAll.mockResolvedValue([{ id: 3 }, { id: 4 }]);
queryBuilder.buildWhere.mockReturnValue('GLOBAL_WHERE' as never);
queryBuilder.buildOrderBy.mockReturnValue(['GLOBAL_ORDER'] as never);
bookRepo.findCards.mockResolvedValue({
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
progressRows: [],
statusRows: [],
total: 0,
});
await service.globalQuery(user, { filter: null, sort: [], pagination: { page: 0, size: 10 } } as never);
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(null, {
accessibleLibraryIds: [3, 4],
userId: 7,
timeZone: 'UTC',
contentFilters: EMPTY_CONTENT_FILTER_RULES,
q: undefined,
});
expect(bookRepo.findCards).toHaveBeenCalledWith({
where: 'GLOBAL_WHERE',
orderBy: ['GLOBAL_ORDER'],
limit: 10,
offset: 0,
userId: 7,
});
});
it('delegates getProgress to repository', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ id: 77 });
vi.spyOn(service, 'verifyFileAccess').mockResolvedValue({ id: 1 } as never);
bookRepo.findProgress.mockResolvedValue({ percentage: 12 });
await expect(service.getProgress(user.id, 1, user)).resolves.toEqual({ percentage: 12 });
expect(bookRepo.findProgress).toHaveBeenCalledWith(77, 1);
});
describe('updatePersonalNote', () => {
it('verifies access, persists the note, and returns the refreshed detail', async () => {
const { service, userBookNoteService } = makeService();
const user = makeUser({ id: 77 });
const accessSpy = vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
const detailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 10, personalNote: 'Loved it.' } as never);
userBookNoteService.setNote.mockResolvedValue({ note: 'Loved it.', updatedAt: '2026-07-01T00:00:00.000Z' });
const result = await service.updatePersonalNote(10, { note: 'Loved it.' }, user);
expect(accessSpy).toHaveBeenCalledWith(10, user);
expect(userBookNoteService.setNote).toHaveBeenCalledWith(77, 10, 'Loved it.');
expect(detailSpy).toHaveBeenCalledWith(10, user);
expect(result).toEqual({ id: 10, personalNote: 'Loved it.' });
});
it('normalizes an undefined note to null when clearing', async () => {
const { service, userBookNoteService } = makeService();
const user = makeUser({ id: 77 });
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 10, personalNote: null } as never);
await service.updatePersonalNote(10, {}, user);
expect(userBookNoteService.setNote).toHaveBeenCalledWith(77, 10, null);
});
});
describe('setReadStatus', () => {
it('requires at least one updatable field', async () => {
const { service } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
await expect(service.setReadStatus(10, {} as never, makeUser())).rejects.toThrow(
'At least one of status, startedAt, or finishedAt is required',
);
});
it('updates status only and returns canonical date-only payload', async () => {
const { service, userBookStatusService } = makeService();
const user = makeUser({ id: 77 });
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.updateManual.mockResolvedValue({
status: 'reading',
source: 'manual',
startedAt: '2026-04-10T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-04-11T00:00:00.000Z',
});
const result = await service.setReadStatus(10, { status: 'reading' }, user);
expect(userBookStatusService.updateManual).toHaveBeenCalledWith(77, 10, { status: 'reading' });
expect(result).toEqual({
status: 'reading',
source: 'manual',
startedAt: '2026-04-10',
finishedAt: null,
updatedAt: '2026-04-11T00:00:00.000Z',
});
});
it('ignores omitted date fields materialized as undefined by DTO transformation', async () => {
const { service, userBookStatusService } = makeService();
const user = makeUser({ id: 77 });
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.updateManual.mockResolvedValue({
status: 'reading',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-04-11T00:00:00.000Z',
});
const transformedDto = {
status: 'reading' as const,
startedAt: undefined,
finishedAt: undefined,
};
await service.setReadStatus(10, transformedDto, user);
expect(userBookStatusService.updateManual).toHaveBeenCalledWith(77, 10, { status: 'reading' });
});
it('accepts ISO date input, applies timezone normalization, and persists as manual patch dates', async () => {
const { service, userBookStatusService } = makeService();
const user = makeUser({ id: 12, settings: { timezone: 'America/New_York' } });
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.updateManual.mockImplementation(
(_userId: number, _bookId: number, patch: { status?: string; startedAt?: Date | null; finishedAt?: Date | null }) => ({
status: (patch.status as 'reading' | undefined) ?? 'reading',
source: 'manual',
startedAt: patch.startedAt instanceof Date ? patch.startedAt.toISOString() : null,
finishedAt: patch.finishedAt instanceof Date ? patch.finishedAt.toISOString() : null,
updatedAt: '2026-04-11T00:00:00.000Z',
}),
);
const result = await service.setReadStatus(
10,
{
startedAt: '2026-01-01T02:30:00.000Z',
finishedAt: '2026-01-15',
},
user,
);
expect(userBookStatusService.updateManual).toHaveBeenCalledOnce();
const patch = userBookStatusService.updateManual.mock.calls[0]?.[2];
expect(patch.startedAt).toBeInstanceOf(Date);
expect(patch.finishedAt).toBeInstanceOf(Date);
expect(result.startedAt).toBe('2025-12-31');
expect(result.finishedAt).toBe('2026-01-15');
});
it('rejects future dates', async () => {
const { service, userBookStatusService } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-20T12:00:00.000Z'));
await expect(service.setReadStatus(10, { startedAt: '2026-05-21' }, makeUser())).rejects.toMatchObject({
response: {
message: 'startedAt cannot be in the future',
errorCode: 'READING_DATE_STARTED_IN_FUTURE',
},
});
expect(userBookStatusService.updateManual).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('delegates date ordering against the resolved reading attempt', async () => {
const { service, userBookStatusService } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.findOne.mockResolvedValue({
status: 'reading',
source: 'manual',
startedAt: '2026-01-01T00:00:00.000Z',
finishedAt: '2026-01-10T00:00:00.000Z',
updatedAt: '2026-01-10T00:00:00.000Z',
});
userBookStatusService.updateManual.mockResolvedValue({
status: 'rereading',
source: 'manual',
startedAt: '2026-01-15',
finishedAt: null,
updatedAt: '2026-01-15T00:00:00.000Z',
});
await service.setReadStatus(10, { status: 'rereading', startedAt: '2026-01-15' }, makeUser());
expect(userBookStatusService.findOne).not.toHaveBeenCalled();
expect(userBookStatusService.updateManual).toHaveBeenCalledWith(
1,
10,
{ status: 'rereading', startedAt: expect.any(Date) },
{ startedOn: '2026-01-15' },
);
});
it('falls back to UTC when user timezone is invalid', async () => {
const { service, userBookStatusService } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.updateManual.mockImplementation((_userId: number, _bookId: number, patch: { startedAt?: Date | null }) => ({
status: 'reading',
source: 'manual',
startedAt: patch.startedAt instanceof Date ? patch.startedAt.toISOString() : null,
finishedAt: null,
updatedAt: '2026-04-11T00:00:00.000Z',
}));
const result = await service.setReadStatus(
10,
{
startedAt: '2026-01-01T00:30:00.000+05:00',
},
makeUser({ settings: { timezone: 'Invalid/Zone' } }),
);
expect(result.startedAt).toBe('2025-12-31');
});
it('supports explicit null clearing for reading dates', async () => {
const { service, userBookStatusService } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
userBookStatusService.updateManual.mockResolvedValue({
status: 'reading',
source: 'manual',
startedAt: null,
finishedAt: null,
updatedAt: '2026-04-11T00:00:00.000Z',
});
const result = await service.setReadStatus(10, { startedAt: null, finishedAt: null }, makeUser());
expect(userBookStatusService.updateManual).toHaveBeenCalledWith(1, 10, { startedAt: null, finishedAt: null });
expect(result.startedAt).toBeNull();
expect(result.finishedAt).toBeNull();
});
});
});
describe('embedding and failpoint behavior', () => {
it('embedAll returns queued=0 when embedder is unavailable', async () => {
const { service } = makeService();
(service as unknown as { embedder?: unknown }).embedder = undefined;
await expect(service.embedAll()).resolves.toEqual({ queued: 0 });
});
it('runEmbeddings logs per-item failures and continues', async () => {
const { service, embedder } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
embedder.embedBook.mockRejectedValueOnce(new Error('embedding failed')).mockResolvedValueOnce(undefined);
await (service as any).runEmbeddings([1, 2]);
expect(embedder.embedBook).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalled();
});
it('validates and clears metadata failpoints for tests', () => {
const { service } = makeService();
expect(() => service.setMetadataUpdateFailpointForTests('not-a-stage' as never)).toThrow('Unknown metadata update failpoint');
service.setMetadataUpdateFailpointForTests('afterTagsReplace');
expect(() => (service as any).throwIfMetadataUpdateFailpoint('afterTagsReplace')).toThrow(
'Metadata update failpoint triggered: afterTagsReplace',
);
service.setMetadataUpdateFailpointForTests('afterGenresReplace');
service.clearMetadataUpdateFailpointForTests();
expect(() => (service as any).throwIfMetadataUpdateFailpoint('afterGenresReplace')).not.toThrow();
});
});
describe('metadata workflow error branches', () => {
it('refreshMetadata logs and rethrows provider failures', async () => {
const { service, bookRepo, pipeline } = makeService();
const user = makeUser();
bookRepo.findById.mockResolvedValue({
book: { books: { id: 1, libraryId: 7 }, book_metadata: { title: 'Old', isbn13: null, isbn10: null } },
authorRows: [{ id: 1, name: 'Author', sortName: null }],
genreRows: [],
});
pipeline.runWithSources.mockRejectedValue(new Error('provider timeout'));
await expect(service.refreshMetadata(1, false, user)).rejects.toThrow('provider timeout');
});
it('bulkRefreshMetadata increments failed count when individual books fail', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 7 },
]);
const refreshSpy = vi
.spyOn(service, 'refreshMetadata')
.mockRejectedValueOnce(new Error('failed one'))
.mockResolvedValueOnce({ id: 2 } as never);
const onProgress = vi.fn();
const result = await service.bulkRefreshMetadata([1, 2], user, onProgress);
expect(result).toEqual({ processed: 1, failed: 1 });
expect(refreshSpy).toHaveBeenCalledTimes(2);
expect(onProgress).toHaveBeenCalledTimes(2);
});
it('bulkRefreshMetadata returns zero counts without checking access when no books are requested', async () => {
const { service, bookRepo } = makeService();
await expect(service.bulkRefreshMetadata([], makeUser())).resolves.toEqual({ processed: 0, failed: 0 });
expect(bookRepo.findLibraryIdsByBookIds).not.toHaveBeenCalled();
});
it('bulkRefreshMetadata logs and rethrows access failures before iteration starts', async () => {
const { service } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
const user = makeUser();
const accessError = new ForbiddenException('denied');
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(accessError);
await expect(service.bulkRefreshMetadata([1], user)).rejects.toThrow(accessError);
expect(warnSpy).toHaveBeenCalled();
});
it('bulkReExtractCover aborts early when cancellation is requested', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/1.epub', format: 'epub' }]);
const result = await service.bulkReExtractCover([1], user, undefined, { isCancelled: () => true });
expect(result).toEqual({ processed: 0, updated: 0 });
});
it('bulkReExtractCover returns zero counts without checking access when no books are requested', async () => {
const { service, bookRepo } = makeService();
await expect(service.bulkReExtractCover([], makeUser())).resolves.toEqual({ processed: 0, updated: 0 });
expect(bookRepo.findLibraryIdsByBookIds).not.toHaveBeenCalled();
});
it('bulkReExtractCover logs and rethrows access failures before loading files', async () => {
const { service } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
const user = makeUser();
const accessError = new ForbiddenException('denied');
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(accessError);
await expect(service.bulkReExtractCover([1], user)).rejects.toThrow(accessError);
expect(warnSpy).toHaveBeenCalled();
});
});
describe('bulk metadata actions', () => {
it('bulkSetRating updates ratings and queues file writes and score recalculation', async () => {
const { service, bookRepo, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 42 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkSetRating([3, 5], 4, user);
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4, 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(1, 3, 42);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(2, 5, 42);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 5);
});
it('bulkSetRating skips books where rating is metadata-locked', async () => {
const { service, bookRepo, fileWriteService, fileRenameService, scoreService, bookMetadataLockService } = makeService();
const user = makeUser({ id: 42 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getBookIdsWithLockedField.mockResolvedValue(new Set([5]));
await service.bulkSetRating([3, 5], 4, user);
expect(bookMetadataLockService.getBookIdsWithLockedField).toHaveBeenCalledWith([3, 5], 'rating');
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledTimes(1);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(3, 'auto', 42);
expect(fileRenameService.scheduleRename).toHaveBeenCalledTimes(1);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(3, 42);
expect(scoreService.calculateAndSave).toHaveBeenCalledTimes(1);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(3);
});
it('bulkSetRating propagates score recalculation failures', async () => {
const { service, bookRepo, scoreService } = makeService();
const user = makeUser({ id: 42 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
scoreService.calculateAndSave.mockRejectedValue(new Error('score exploded'));
await expect(service.bulkSetRating([3], 4, user)).rejects.toThrow('score exploded');
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42);
});
it('bulkSetMetadata updates a single metadata field and queues follow-up work', async () => {
const { service, bookRepo, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 11 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkSetMetadata([7, 9], 'language', 'fr', user);
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenCalledWith(
[7, 9],
expect.objectContaining({ language: 'fr', updatedAt: expect.any(Date) }),
);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 7, 'auto', 11);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 9, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(1, 7, 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(2, 9, 11);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 7);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 9);
});
it('bulkSetMetadata normalizes publisher and series text fields', async () => {
const { service, bookRepo } = makeService();
const user = makeUser({ id: 11 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkSetMetadata([7], 'publisher', ' Tor\t Books ', user);
await service.bulkSetMetadata([7], 'seriesName', ' Stormlight Archive ', user);
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenNthCalledWith(
1,
[7],
expect.objectContaining({ publisher: 'Tor Books', updatedAt: expect.any(Date) }),
);
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenNthCalledWith(
2,
[7],
expect.objectContaining({ seriesName: 'Stormlight Archive', updatedAt: expect.any(Date) }),
);
});
it('bulkSetMetadata skips books where the field is metadata-locked', async () => {
const { service, bookRepo, fileWriteService, fileRenameService, scoreService, bookMetadataLockService } = makeService();
const user = makeUser({ id: 11 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getBookIdsWithLockedField.mockResolvedValue(new Set([9]));
await service.bulkSetMetadata([7, 9], 'language', 'fr', user);
expect(bookMetadataLockService.getBookIdsWithLockedField).toHaveBeenCalledWith([7, 9], 'language');
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenCalledWith([7], expect.objectContaining({ language: 'fr', updatedAt: expect.any(Date) }));
expect(fileWriteService.scheduleWrite).toHaveBeenCalledTimes(1);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenCalledTimes(1);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(7, 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledTimes(1);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
it('bulkSetMetadata replaces relation fields in a transaction', async () => {
const { service, bookRepo, metadataService, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn(), update: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkSetMetadata([7, 9], 'tags', ['favorite', 'reading'], user);
expect(metadataService.replaceTags).toHaveBeenNthCalledWith(1, 7, ['favorite', 'reading'], { executor: tx });
expect(metadataService.replaceTags).toHaveBeenNthCalledWith(2, 9, ['favorite', 'reading'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 7, 'auto', 11);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 9, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(1, 7, 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(2, 9, 11);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 7);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 9);
});
it('bulkSetMetadata replaces narrators for editable books only', async () => {
const { service, bookRepo, narratorService, fileWriteService, fileRenameService, scoreService, bookMetadataLockService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn(), update: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getBookIdsWithLockedField.mockResolvedValue(new Set([9]));
await service.bulkSetMetadata([7, 9], 'narrators', ['Narrator A', 'narrator a', ' '], user);
expect(narratorService.replaceForBook).toHaveBeenCalledTimes(1);
expect(narratorService.replaceForBook).toHaveBeenCalledWith(7, ['Narrator A'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenCalledTimes(1);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenCalledTimes(1);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(7, 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledTimes(1);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
it('bulkUpdateTags performs all tag changes in one transaction and queues follow-up work', async () => {
const { service, bookRepo, metadataService, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
bookRepo.findTagsByBookIds.mockResolvedValue(new Map([[7, ['existing']]]));
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkUpdateTags([7], 'add', ['new'], user);
expect(bookRepo.findTagsByBookIds).toHaveBeenCalledWith([7], tx);
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['existing', 'new'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(7, 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
it('bulkUpdateTags replaces tags inside the transaction executor', async () => {
const { service, bookRepo, metadataService } = makeService();
const user = makeUser();
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await service.bulkUpdateTags([9], 'replace', ['fresh'], user);
expect(bookRepo.findTagsByBookIds).not.toHaveBeenCalled();
expect(metadataService.replaceTags).toHaveBeenCalledWith(9, ['fresh'], { executor: tx });
});
});
describe('bulkEditMetadata', () => {
function makeFields(overrides: Record<string, unknown> = {}): BulkEditFieldsDto {
const dto = new BulkEditFieldsDto();
Object.assign(dto, overrides);
return dto;
}
it('updates scalar fields and triggers post-metadata effects', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const triggerSpy = vi.spyOn(service, 'triggerPostMetadataUpdateEffects' as never).mockReturnValue(undefined as never);
const fields = makeFields({
publisher: { value: 'Bloomsbury Books' },
language: { value: 'en' },
});
const result = await service.bulkEditMetadata([7, 9], fields, user);
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenCalledWith(
[7, 9],
expect.objectContaining({ publisher: 'Bloomsbury Books', updatedAt: expect.any(Date) }),
tx,
);
expect(result.updatedBooks).toBe(2);
expect(result.fields.publisher).toEqual({ updated: 2, skippedLocked: 0 });
expect(result.fields.language).toEqual({ updated: 2, skippedLocked: 0 });
expect(triggerSpy).toHaveBeenCalledWith([7, 9], user.id);
});
it('updates publishedYear as integer', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
publishedYear: { value: 2001 },
});
const result = await service.bulkEditMetadata([7], fields, user);
expect(result.fields.publishedYear).toEqual({ updated: 1, skippedLocked: 0 });
});
it('replaces authors in replace mode', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
authors: { mode: 'replace', values: ['Author A', 'author a', 'Author B'] },
});
await service.bulkEditMetadata([7, 9], fields, user);
expect(metadataService.replaceAuthors).toHaveBeenCalledTimes(2);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
7,
[
{ name: 'Author A', sortName: null },
{ name: 'Author B', sortName: null },
],
{ executor: tx },
);
});
it('adds authors in add mode by merging with existing', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
bookRepo.findAuthorsByBookIds.mockResolvedValue(new Map([[7, ['Existing Author']]]));
const fields = makeFields({
authors: { mode: 'add', values: ['New Author'] },
});
await service.bulkEditMetadata([7], fields, user);
expect(bookRepo.findAuthorsByBookIds).toHaveBeenCalledWith([7], tx);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
7,
[
{ name: 'Existing Author', sortName: null },
{ name: 'New Author', sortName: null },
],
{ executor: tx },
);
});
it('removes authors in remove mode', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
bookRepo.findAuthorsByBookIds.mockResolvedValue(new Map([[7, ['Author A', 'Author B']]]));
const fields = makeFields({
authors: { mode: 'remove', values: ['Author A'] },
});
await service.bulkEditMetadata([7], fields, user);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(7, [{ name: 'Author B', sortName: null }], { executor: tx });
});
it('replaces tags in replace mode', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
tags: { mode: 'replace', values: ['favorite', 'reading'] },
});
await service.bulkEditMetadata([7, 9], fields, user);
expect(metadataService.replaceTags).toHaveBeenCalledTimes(2);
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['favorite', 'reading'], { executor: tx });
expect(metadataService.replaceTags).toHaveBeenCalledWith(9, ['favorite', 'reading'], { executor: tx });
});
it('adds tags in add mode by merging with existing', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
bookRepo.findTagsByBookIds.mockResolvedValue(new Map([[7, ['existing']]]));
const fields = makeFields({
tags: { mode: 'add', values: ['new'] },
});
await service.bulkEditMetadata([7], fields, user);
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['existing', 'new'], { executor: tx });
});
it('replaces genres in replace mode', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
genres: { mode: 'replace', values: ['Fantasy'] },
});
await service.bulkEditMetadata([7], fields, user);
expect(metadataService.replaceGenres).toHaveBeenCalledWith(7, ['Fantasy'], { executor: tx });
});
it('replaces narrators in replace mode', async () => {
const { service, bookRepo, narratorService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
narrators: { mode: 'replace', values: ['Stephen Fry', 'stephen fry'] },
});
await service.bulkEditMetadata([7], fields, user);
expect(narratorService.replaceForBook).toHaveBeenCalledWith(7, ['Stephen Fry'], { executor: tx });
});
it('skips locked fields per-field and reports correctly', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(
new Map([
[7, ['authors']],
[9, []],
]),
);
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
authors: { mode: 'replace', values: ['New Author'] },
genres: { mode: 'replace', values: ['Fantasy'] },
});
const result = await service.bulkEditMetadata([7, 9], fields, user);
expect(result.fields.authors).toEqual({ updated: 1, skippedLocked: 1 });
expect(result.fields.genres).toEqual({ updated: 2, skippedLocked: 0 });
expect(metadataService.replaceAuthors).toHaveBeenCalledTimes(1);
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(9, [{ name: 'New Author', sortName: null }], { executor: tx });
expect(metadataService.replaceGenres).toHaveBeenCalledTimes(2);
});
it('handles mixed scalar and array fields in one call', async () => {
const { service, bookRepo, metadataService, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
publisher: { value: 'Penguin' },
tags: { mode: 'replace', values: ['classic'] },
});
const result = await service.bulkEditMetadata([7], fields, user);
expect(result.updatedBooks).toBe(1);
expect(result.fields.publisher).toEqual({ updated: 1, skippedLocked: 0 });
expect(result.fields.tags).toEqual({ updated: 1, skippedLocked: 0 });
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenCalled();
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['classic'], { executor: tx });
});
it('throws BadRequestException when no fields are provided', async () => {
const { service } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
const fields = makeFields({});
await expect(service.bulkEditMetadata([7], fields, user)).rejects.toThrow(BadRequestException);
});
it('throws BadRequestException when add mode has empty values', async () => {
const { service } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
const fields = makeFields({
authors: { mode: 'add', values: [] },
});
await expect(service.bulkEditMetadata([7], fields, user)).rejects.toThrow(BadRequestException);
});
it('returns zero updatedBooks when all books have all fields locked', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map([[7, ['publisher']]]));
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
publisher: { value: 'Test' },
});
const result = await service.bulkEditMetadata([7], fields, user);
expect(result.updatedBooks).toBe(0);
expect(result.fields.publisher).toEqual({ updated: 0, skippedLocked: 1 });
expect(bookRepo.bulkUpdateMetadataFields).not.toHaveBeenCalled();
});
it('clears a scalar field when value is null', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
seriesName: { value: null },
});
const result = await service.bulkEditMetadata([7], fields, user);
expect(result.fields.seriesName).toEqual({ updated: 1, skippedLocked: 0 });
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenCalledWith([7], expect.objectContaining({ seriesName: null }), tx);
});
it('verifies library access for all book IDs', async () => {
const { service, bookRepo, bookMetadataLockService } = makeService();
const user = makeUser();
const verifySpy = vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getLockedFieldsMap.mockResolvedValue(new Map());
const tx = {};
bookRepo.withTransaction.mockImplementation(async (cb: (value: unknown) => Promise<unknown>) => cb(tx));
const fields = makeFields({
publisher: { value: 'Test' },
});
await service.bulkEditMetadata([7, 8, 9], fields, user);
expect(verifySpy).toHaveBeenCalledWith([7, 8, 9], user);
});
});
describe('getDetail and metadata extraction', () => {
it('throws NotFoundException when detail lookup has no result', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findById.mockResolvedValue(null);
bookRepo.findCollectionsByBookId.mockResolvedValue([]);
await expect(service.getDetail(9, user)).rejects.toThrow(NotFoundException);
});
it('naturally orders stale audiobook file rows before mapping files and synthesizing chapters', async () => {
const { service, bookRepo, userBookStatusService, comicMetadataService, fileWriteService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findById.mockResolvedValue({
book: {
books: {
id: 9,
libraryId: 7,
primaryFileId: 100,
status: 'present',
folderPath: '/books/dune',
addedAt: new Date('2026-01-01T00:00:00.000Z'),
},
libraries: {
name: 'Main',
formatPriority: ['epub'],
fileWriteEnabled: true,
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 100,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 500,
fileWriteAudioEnabled: true,
fileWriteAudioMaxFileSizeMb: 500,
},
book_metadata: {
title: 'Dune',
subtitle: null,
description: 'Epic',
isbn10: null,
isbn13: '9780441172719',
publisher: 'Ace',
publishedYear: 1965,
language: 'en',
pageCount: 412,
seriesName: 'Dune',
seriesIndex: 1,
rating: 5,
coverSource: 'custom',
lockedFields: ['title'],
googleBooksId: 'g1',
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
audibleId: null,
librofmId: '9781234567890',
koboId: 'beautiful-ugly-3',
comicvineId: null,
ranobedbId: 'ranobe-detail',
chapters: null,
durationSeconds: 90,
abridged: null,
lastWrittenAt: null,
metadataScore: 80,
},
},
authorRows: [{ id: 1, name: 'Frank Herbert', sortName: 'Herbert, Frank' }],
genreRows: [{ name: 'Sci-Fi' }],
tagRows: [{ name: 'classic' }],
fileRows: [
{
id: 101,
format: 'm4b',
role: 'content',
sizeBytes: 20,
absolutePath: '/audio/Part 02.m4b',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
durationSeconds: 60,
},
{
id: 100,
format: 'mp3',
role: 'content',
sizeBytes: 10,
absolutePath: '/audio/Part 01.mp3',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
durationSeconds: 30,
},
],
narratorRows: [{ id: 4, name: 'Narrator Name', sortName: null, displayOrder: 0 }],
communityRatingRows: [
{ provider: MetadataProviderKey.AMAZON, rating: 4.8, ratingCount: 104451, updatedAt: new Date('2026-06-25T00:00:00.000Z') },
{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: new Date('2026-06-24T00:00:00.000Z') },
],
});
userBookStatusService.findOne.mockResolvedValue({
status: 'reading',
source: 'manual',
startedAt: '2026-01-03T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-04T00:00:00.000Z',
});
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
bookRepo.findRatingByBookAndUser.mockResolvedValue(5);
fileWriteService.resolveBookFileWriteStatus.mockReturnValue({
enabled: true,
reason: null,
writableFormats: ['mp3', 'm4b'],
writableFields: [...AUDIO_BOOK_FILE_WRITE_FIELDS],
});
const result = await service.getDetail(9, user);
expect(result.id).toBe(9);
expect(result.communityRatings).toEqual([
{ provider: MetadataProviderKey.AMAZON, rating: 4.8, ratingCount: 104451, updatedAt: '2026-06-25T00:00:00.000Z' },
{ provider: MetadataProviderKey.HARDCOVER, rating: 4.25, ratingCount: 12345, updatedAt: '2026-06-24T00:00:00.000Z' },
]);
expect(result.audioMetadata?.chapters).toEqual([
{ title: 'Part 01', startMs: 0 },
{ title: 'Part 02', startMs: 30_000 },
]);
expect(result.files.map((file) => file.filename)).toEqual(['Part 01.mp3', 'Part 02.m4b']);
expect(result.readStatus).toEqual({
status: 'reading',
source: 'manual',
startedAt: '2026-01-03T00:00:00.000Z',
finishedAt: null,
updatedAt: '2026-01-04T00:00:00.000Z',
});
expect(result.files[0]?.role).toBe('primary');
expect(result.collections).toEqual([{ id: 3, name: 'Favorites' }]);
expect(result.lockedFields).toEqual(['title']);
expect(result.comicMetadata).toEqual(expect.objectContaining({ issueNumber: '1', teams: ['House Atreides'] }));
expect(result.rating).toBe(5);
expect(result.providerIds.google).toBe('g1');
expect(result.providerIds.librofm).toBe('9781234567890');
expect(result.providerIds.kobo).toBe('beautiful-ugly-3');
expect(result.providerIds.ranobedb).toBe('ranobe-detail');
expect(result.fileWriteStatus).toEqual({
enabled: true,
reason: null,
writableFormats: ['mp3', 'm4b'],
writableFields: [...AUDIO_BOOK_FILE_WRITE_FIELDS],
});
expect(fileWriteService.resolveBookFileWriteStatus).toHaveBeenCalledWith(
expect.objectContaining({ fileWriteEnabled: true }),
expect.any(Array),
100,
);
});
it('preserves null personal detail fields when no user-specific state exists', async () => {
const { service, bookRepo, userBookStatusService, comicMetadataService } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findById.mockResolvedValue({
book: {
books: {
id: 12,
libraryId: 3,
primaryFileId: 400,
status: 'present',
folderPath: '/books/foundation',
addedAt: new Date('2026-02-01T00:00:00.000Z'),
},
libraries: { name: 'Main', formatPriority: null },
book_metadata: {
title: 'Foundation',
subtitle: null,
description: null,
isbn10: null,
isbn13: null,
publisher: null,
publishedYear: null,
language: null,
pageCount: null,
seriesName: null,
seriesIndex: null,
rating: null,
coverSource: null,
lockedFields: null,
googleBooksId: null,
goodreadsId: null,
amazonId: null,
hardcoverId: null,
openLibraryId: null,
itunesId: null,
audibleId: null,
koboId: null,
comicvineId: null,
ranobedbId: null,
chapters: null,
durationSeconds: null,
abridged: null,
lastWrittenAt: null,
metadataScore: null,
},
},
authorRows: [{ id: 2, name: 'Isaac Asimov', sortName: 'Asimov, Isaac' }],
genreRows: [{ name: 'Sci-Fi' }],
tagRows: [],
fileRows: [
{
id: 400,
format: 'epub',
role: 'content',
sizeBytes: 42,
absolutePath: '/books/foundation.epub',
createdAt: new Date('2026-02-01T00:00:00.000Z'),
durationSeconds: null,
},
],
narratorRows: [],
communityRatingRows: [],
});
userBookStatusService.findOne.mockResolvedValue(null);
comicMetadataService.findByBookId.mockResolvedValue(null);
bookRepo.findCollectionsByBookId.mockResolvedValue([]);
bookRepo.findRatingByBookAndUser.mockResolvedValue(null);
const result = await service.getDetail(12, user);
expect(result.readStatus).toBeNull();
expect(result.rating).toBeNull();
expect(result.communityRatings).toEqual([]);
expect(result.audioMetadata).toBeNull();
expect(result.collections).toEqual([]);
expect(result.comicMetadata).toBeNull();
expect(result.lockedFields).toEqual([]);
expect(result.formatPriority).toEqual([]);
expect(result.fileWriteStatus).toEqual({ enabled: false, reason: 'library_disabled', writableFormats: [], writableFields: [] });
expect(result.files).toEqual([
expect.objectContaining({
id: 400,
role: 'primary',
filename: 'foundation.epub',
}),
]);
});
it('getMetadataFromFile handles missing or unsupported primary files', async () => {
const { service, bookRepo } = makeService();
const user = makeUser();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValueOnce(null).mockResolvedValueOnce({ absolutePath: '/books/a.bin', format: null });
await expect(service.getMetadataFromFile(1, user)).rejects.toThrow(NotFoundException);
await expect(service.getMetadataFromFile(1, user)).resolves.toEqual({});
});
it('getMetadataFromFile returns an empty object for unsupported but known file formats', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/archive.azw3', format: 'azw3' });
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual({});
});
it('maps epub metadata to update payload shape', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/dune.epub', format: 'epub' });
mockExtractEpubMetadata.mockResolvedValue({
title: 'Dune',
subtitle: 'Book One',
description: 'Desc',
publisher: 'Ace',
publishedYear: 1965,
language: 'en',
pageCount: 412,
isbn10: '0441172717',
isbn13: '9780441172719',
seriesName: 'Dune',
seriesIndex: 1,
googleBooksId: 'google-file',
ranobedbId: 'ranobe-file',
authors: [{ name: 'Frank Herbert', sortName: null }],
genres: ['Sci-Fi'],
tags: [],
} as never);
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual(
expect.objectContaining({
title: 'Dune',
subtitle: 'Book One',
pageCount: 412,
googleBooksId: 'google-file',
ranobedbId: 'ranobe-file',
authors: ['Frank Herbert'],
genres: ['Sci-Fi'],
}),
);
});
it('maps audio file metadata to update payload shape', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/project-hail-mary.m4b', format: 'm4b' });
mockExtractAudioMetadata.mockResolvedValue({
title: 'Project Hail Mary',
subtitle: null,
description: 'A lone astronaut saves the day.',
publisher: 'Audible Studios',
publishedYear: 2021,
language: 'eng',
seriesName: null,
seriesIndex: null,
authors: [{ name: 'Andy Weir', sortName: null }],
genres: ['Science Fiction'],
audibleId: 'B08G9PRS1K',
narrators: ['Ray Porter'],
durationSeconds: 57600,
chapters: [{ title: 'Chapter 1', startMs: 0 }],
coverBytes: Buffer.from('cover'),
});
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual({
title: 'Project Hail Mary',
description: 'A lone astronaut saves the day.',
publisher: 'Audible Studios',
publishedYear: 2021,
language: 'eng',
authors: ['Andy Weir'],
genres: ['Science Fiction'],
audibleId: 'B08G9PRS1K',
narrators: ['Ray Porter'],
durationSeconds: 57600,
});
});
it('returns an empty object when audio extraction finds no usable tags', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/empty.m4b', format: 'm4b' });
mockExtractAudioMetadata.mockResolvedValue({
title: null,
subtitle: null,
description: null,
publisher: null,
publishedYear: null,
language: null,
seriesName: null,
seriesIndex: null,
authors: [],
genres: [],
audibleId: null,
narrators: [],
durationSeconds: null,
chapters: [],
coverBytes: null,
});
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual({});
});
it('maps pdf metadata and emits parser warnings', async () => {
const { service, bookRepo } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: { warn: (message: string) => void } }).logger, 'warn').mockImplementation();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile.mockResolvedValue({ absolutePath: '/books/doc.pdf', format: 'pdf' });
mockParsePdfFile.mockImplementation(
(
_path: string,
options: {
onWarning: (warning: {
code: string;
absolutePath: string;
sizeBytes?: number;
thresholdBytes?: number;
errorClass?: string;
errorMessage?: string;
}) => void;
},
) => {
options.onWarning({
code: 'buffered-large-pdf',
absolutePath: '/books/doc.pdf',
sizeBytes: 100,
thresholdBytes: 10,
});
options.onWarning({
code: 'parse-warning',
absolutePath: '/books/doc.pdf',
errorClass: 'ParseWarning',
errorMessage: 'metadata truncated',
});
return {
title: 'PDF Title',
publisher: 'Pub',
pageCount: 10,
ranobedbId: 'pdf-ranobe',
authors: [{ name: 'Author One', sortName: null }],
genres: ['Tech'],
} as never;
},
);
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual(
expect.objectContaining({
title: 'PDF Title',
publisher: 'Pub',
pageCount: 10,
ranobedbId: 'pdf-ranobe',
authors: ['Author One'],
genres: ['Tech'],
}),
);
expect(warnSpy).toHaveBeenCalled();
});
it('maps mobi, comic archive, and fb2 metadata formats', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
bookRepo.findPrimaryFile
.mockResolvedValueOnce({ absolutePath: '/books/dune.mobi', format: 'mobi' })
.mockResolvedValueOnce({ absolutePath: '/books/comic.cbr', format: 'cbr' })
.mockResolvedValueOnce({ absolutePath: '/books/novel.fb2', format: 'fb2' });
mockParseMobiFile.mockResolvedValue({
title: 'Mobi Title',
description: 'Mobi Desc',
publisher: 'Mobi Pub',
publishedDate: '2001-02-03',
language: 'en',
isbn: '9781111111111',
authors: ['Mobius'],
tags: ['Adventure'],
} as never);
mockExtractCbrMetadata.mockResolvedValue({
title: 'Comic Title',
subtitle: 'Issue',
description: 'Comic Desc',
publisher: 'Comic Pub',
publishedYear: 2012,
language: 'en',
pageCount: 40,
isbn10: null,
isbn13: null,
seriesName: 'Series',
seriesIndex: 4,
authors: [{ name: 'Writer', sortName: null }],
genres: [],
tags: ['Comics'],
ranobedbId: 'comic-ranobe',
comicMetadata: { issueNumber: '4' },
} as never);
mockParseFb2File.mockResolvedValue({
title: 'FB2 Title',
description: 'FB2 Desc',
publishedYear: 1999,
language: 'ru',
seriesName: 'FB2 Series',
seriesIndex: 2,
authors: ['Author'],
genres: ['Drama'],
} as never);
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual(
expect.objectContaining({
title: 'Mobi Title',
publishedYear: 2001,
authors: ['Mobius'],
}),
);
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual(
expect.objectContaining({
title: 'Comic Title',
ranobedbId: 'comic-ranobe',
comicMetadata: { issueNumber: '4' },
}),
);
await expect(service.getMetadataFromFile(5, makeUser())).resolves.toEqual(
expect.objectContaining({
title: 'FB2 Title',
genres: ['Drama'],
}),
);
});
});
describe('export edge cases', () => {
it('resolves missing sizeBytes from disk stat for projected exports', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/dune.epub', format: 'epub', sizeBytes: null }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune' })]);
mockStat.mockResolvedValue({ size: 321 } as never);
const plan = await service.getExportFiles([1], user, 'primary');
expect(plan.projectedBytes).toBe(321);
expect(plan.files).toEqual([{ absolutePath: '/books/dune.epub', zipPath: 'Dune.epub', sizeBytes: 321 }]);
});
it('resolves missing sizeBytes from disk stat and throws when file is missing', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/missing.epub', format: 'epub', sizeBytes: null }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Missing' })]);
mockStat.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }));
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow(NotFoundException);
});
it('rethrows unexpected stat failures while resolving export file sizes', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{title}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/secret.epub', format: 'epub', sizeBytes: null }]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Secret' })]);
mockStat.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' }));
await expect(service.getExportFiles([1], user, 'primary')).rejects.toThrow('permission denied');
});
it('sorts equal-order export files by absolute path as final tie-breaker', async () => {
const { service, bookRepo, appSettings } = makeService();
const user = makeUser();
appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}');
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 7 }]);
bookRepo.findAllFilesByBookIds.mockResolvedValue([
{ bookId: 1, absolutePath: '/books/zeta.epub', format: 'epub', sizeBytes: 1, sortOrder: 0 },
{ bookId: 1, absolutePath: '/books/alpha.epub', format: 'epub', sizeBytes: 1, sortOrder: 0 },
]);
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Any' })]);
const plan = await service.getExportFiles([1], user, 'all');
expect(plan.files.map((file) => file.absolutePath)).toEqual(['/books/alpha.epub', '/books/zeta.epub']);
});
});
describe('executeBooksQuery', () => {
const emptyCardQueryResult = {
rows: [],
authorRows: [],
fileRows: [],
genreRows: [],
tagRows: [],
progressRows: [],
statusRows: [],
narratorRows: [],
total: 0,
};
afterEach(() => {
vi.restoreAllMocks();
});
it('returns results from findCards when collapseSeries is false', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [{ field: 'title', dir: 'asc' }],
filter: undefined,
collapseSeries: false,
};
queryBuilder.buildOrderBy.mockReturnValue('order-by');
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
const result = await service.executeBooksQuery(12, undefined, query);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined, {});
expect(bookRepo.findCards).toHaveBeenCalledWith({
where: undefined,
orderBy: 'order-by',
limit: 50,
offset: 0,
userId: 12,
});
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
expect(result).toEqual({ items: [], total: 0, page: 0, size: 50 });
});
it('passes collection ordering context to an uncollapsed collectionOrder query', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [{ field: 'collectionOrder', dir: 'asc' }],
filter: undefined,
collapseSeries: false,
};
queryBuilder.buildOrderBy.mockReturnValue('collection-order');
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(12, undefined, query, { defaultCollectionId: 42 });
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, undefined, { defaultCollectionId: 42 });
expect(bookRepo.findCards).toHaveBeenCalledWith(expect.objectContaining({ orderBy: 'collection-order' }));
});
it('rejects collectionOrder when the query is not scoped to a collection', async () => {
const { service, bookRepo } = makeService();
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [{ field: 'collectionOrder', dir: 'asc' }],
filter: undefined,
collapseSeries: false,
};
await expect(service.executeBooksQuery(12, undefined, query)).rejects.toThrow('This sort is only available inside a collection');
expect(bookRepo.findCards).not.toHaveBeenCalled();
});
it('rejects collectionOrder on the collapsed path too', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [{ field: 'collectionOrder', dir: 'asc' }],
filter: undefined,
collapseSeries: true,
};
await expect(service.executeBooksQuery(12, undefined, query)).rejects.toThrow('This sort is only available inside a collection');
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
});
it('returns results from findCardsCollapsed when collapseSeries is true', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const hasSeriesSelectionFilterSpy = vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
const query: BookQuery = {
pagination: { page: 1, size: 25 },
sort: [{ field: 'title', dir: 'asc' }],
filter: undefined,
collapseSeries: true,
};
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
const result = await service.executeBooksQuery(12, 'where' as never, query);
expect(hasSeriesSelectionFilterSpy).toHaveBeenCalledWith(undefined);
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
where: 'where',
sort: query.sort,
limit: 25,
offset: 25,
userId: 12,
customFieldTypes: undefined,
});
expect(bookRepo.findCards).not.toHaveBeenCalled();
expect(queryBuilder.buildOrderBy).not.toHaveBeenCalled();
expect(result).toEqual({ items: [], total: 0, page: 1, size: 25 });
});
it('passes collection ordering context to a collapsed collectionOrder query', async () => {
const { service, bookRepo } = makeService();
vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [{ field: 'collectionOrder', dir: 'asc' }],
filter: undefined,
collapseSeries: true,
};
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(12, undefined, query, { defaultCollectionId: 42 });
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
where: undefined,
sort: query.sort,
limit: 50,
offset: 0,
userId: 12,
customFieldTypes: undefined,
defaultCollectionId: 42,
});
});
it('resolves custom metadata field types for custom sort fields', async () => {
const { service, bookRepo, queryBuilder, customMetadataService } = makeService();
const fieldTypes = new Map([[7, 'number' as const]]);
customMetadataService.getActiveFieldTypes.mockResolvedValue(fieldTypes);
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [
{ field: 'custom:7', dir: 'asc' },
{ field: 'title', dir: 'asc' },
],
filter: undefined,
collapseSeries: false,
};
await service.executeBooksQuery(12, undefined, query);
expect(customMetadataService.getActiveFieldTypes).toHaveBeenCalledWith([7]);
expect(queryBuilder.buildOrderBy).toHaveBeenCalledWith(query.sort, 12, fieldTypes, {});
});
it('does not query custom metadata field types when no custom sort is used', async () => {
const { service, bookRepo, customMetadataService } = makeService();
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
const query: BookQuery = {
pagination: { page: 0, size: 50 },
sort: [{ field: 'title', dir: 'asc' }],
filter: undefined,
collapseSeries: false,
};
await service.executeBooksQuery(12, undefined, query);
expect(customMetadataService.getActiveFieldTypes).not.toHaveBeenCalled();
});
it('returns collapsed results when the filter only requires a series', async () => {
const { service, bookRepo } = makeService();
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
const filter = {
type: 'group' as const,
join: 'AND' as const,
rules: [{ type: 'rule' as const, field: 'series' as const, operator: 'isNotEmpty' as const }],
};
await service.executeBooksQuery(12, 'where' as never, {
pagination: { page: 0, size: 50 },
sort: [{ field: 'title', dir: 'asc' }],
filter,
collapseSeries: true,
});
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith(
expect.objectContaining({ where: 'where', sort: [{ field: 'title', dir: 'asc' }], userId: 12 }),
);
expect(bookRepo.findCards).not.toHaveBeenCalled();
});
it('returns collapsed results when series selection rules belong to the containing scope', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const scopeFilter = {
type: 'group' as const,
join: 'OR' as const,
rules: [
{ type: 'rule' as const, field: 'series' as const, operator: 'contains' as const, value: 'Batman' },
{ type: 'rule' as const, field: 'series' as const, operator: 'contains' as const, value: 'Detective Comics' },
],
};
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(
12,
'scope-where' as never,
{
pagination: { page: 0, size: 50 },
sort: [{ field: 'series', dir: 'asc' }],
filter: scopeFilter,
collapseSeries: true,
},
{ seriesSelectionFilter: undefined },
);
expect(bookRepo.findCardsCollapsed).toHaveBeenCalledWith({
where: 'scope-where',
sort: [{ field: 'series', dir: 'asc' }],
limit: 50,
offset: 0,
userId: 12,
});
expect(bookRepo.findCards).not.toHaveBeenCalled();
expect(queryBuilder.buildOrderBy).not.toHaveBeenCalled();
});
it('returns expanded results when a temporary series filter is supplied separately', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const temporaryFilter = {
type: 'group' as const,
join: 'AND' as const,
rules: [{ type: 'rule' as const, field: 'series' as const, operator: 'eq' as const, value: 'Batman' }],
};
const effectiveFilter = {
type: 'group' as const,
join: 'AND' as const,
rules: [
{
type: 'group' as const,
join: 'AND' as const,
rules: [{ type: 'rule' as const, field: 'language' as const, operator: 'eq' as const, value: 'en' }],
},
temporaryFilter,
],
};
queryBuilder.buildOrderBy.mockReturnValue('order-by');
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(
12,
'scope-where' as never,
{
pagination: { page: 0, size: 50 },
sort: [{ field: 'title', dir: 'asc' }],
filter: effectiveFilter,
collapseSeries: true,
},
{ seriesSelectionFilter: temporaryFilter },
);
expect(bookRepo.findCards).toHaveBeenCalledWith({
where: 'scope-where',
orderBy: 'order-by',
limit: 50,
offset: 0,
userId: 12,
});
expect(bookRepo.findCardsCollapsed).not.toHaveBeenCalled();
});
it('does not call logger.warn when query completes fast', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(400);
queryBuilder.buildOrderBy.mockReturnValue('order-by');
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(12, undefined, {
pagination: { page: 0, size: 50 },
sort: [],
filter: undefined,
collapseSeries: false,
});
expect(warnSpy).not.toHaveBeenCalled();
});
it('calls logger.warn when a non-collapsed query is slow', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(600);
queryBuilder.buildOrderBy.mockReturnValue('order-by');
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(12, undefined, {
pagination: { page: 0, size: 50 },
sort: [],
filter: undefined,
collapseSeries: false,
});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
'[book.list_query] [end] userId=12 page=0 size=50 filterCount=0 sortFields=0 collapseSeries=false resultCount=0 durationMs=600 - slow query',
);
});
it('calls logger.warn when a collapsed query is slow', async () => {
const { service, bookRepo } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(600);
vi.spyOn(BookQueryBuilder, 'hasSeriesSelectionFilter').mockReturnValue(false);
bookRepo.findCardsCollapsed.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(12, undefined, {
pagination: { page: 0, size: 50 },
sort: [],
filter: undefined,
collapseSeries: true,
});
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
'[book.list_query] [end] userId=12 page=0 size=50 filterCount=0 sortFields=0 collapseSeries=true resultCount=0 durationMs=600 - slow query',
);
});
it('includes the expected fields in the slow-query warning message', async () => {
const { service, bookRepo, queryBuilder } = makeService();
const warnSpy = vi.spyOn((service as unknown as { logger: Logger }).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn(Date, 'now').mockReturnValueOnce(0).mockReturnValueOnce(900);
queryBuilder.buildOrderBy.mockReturnValue('order-by');
bookRepo.findCards.mockResolvedValue(emptyCardQueryResult);
await service.executeBooksQuery(77, undefined, {
pagination: { page: 2, size: 25 },
sort: [
{ field: 'title', dir: 'asc' },
{ field: 'author', dir: 'desc' },
],
filter: {
type: 'group',
join: 'AND',
rules: [
{ type: 'rule', field: 'title', operator: 'contains', value: 'space' },
{ type: 'rule', field: 'language', operator: 'eq', value: 'en' },
],
},
collapseSeries: false,
});
expect(warnSpy).toHaveBeenCalledWith(
'[book.list_query] [end] userId=77 page=2 size=25 filterCount=2 sortFields=2 collapseSeries=false resultCount=0 durationMs=900 - slow query',
);
});
});
describe('resolveSelectionToIds', () => {
it('verifies access and returns explicit book ids', async () => {
const { service } = makeService();
const user = makeUser();
const verifySpy = vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
await expect(service.resolveSelectionToIds({ bookIds: [1, 2] }, user)).resolves.toEqual([1, 2]);
expect(verifySpy).toHaveBeenCalledWith([1, 2], user);
});
it('throws when bookIds and query are both provided', async () => {
const { service } = makeService();
await expect(service.resolveSelectionToIds({ bookIds: [1], query: {} }, makeUser())).rejects.toThrow(BadRequestException);
});
it('propagates library access errors for explicit book ids', async () => {
const { service } = makeService();
const error = new ForbiddenException('no access');
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockRejectedValue(error);
await expect(service.resolveSelectionToIds({ bookIds: [1, 2] }, makeUser())).rejects.toBe(error);
});
it('rejects explicit selections when any requested book id is missing', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 5 }]);
await expect(service.resolveSelectionToIds({ bookIds: [1, 2] }, makeUser())).rejects.toThrow(NotFoundException);
expect(bookRepo.findLibraryIdsByBookIds).toHaveBeenCalledWith([1, 2]);
});
it('resolves query selections across all accessible libraries', async () => {
const { service, bookRepo, libraryService, queryBuilder } = makeService();
const user = makeUser({ id: 42 });
const filter = { type: 'group', join: 'AND', rules: [] } as const;
libraryService.findAll.mockResolvedValue([{ id: 5 }, { id: 9 }]);
queryBuilder.buildWhere.mockReturnValue('where-clause');
bookRepo.findIdsByWhere.mockResolvedValue([10, 11]);
await expect(service.resolveSelectionToIds({ query: { filter, q: 'dune' } }, user)).resolves.toEqual([10, 11]);
expect(libraryService.findAll).toHaveBeenCalledWith(user);
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(filter, {
accessibleLibraryIds: [5, 9],
implicitLibraryId: undefined,
userId: 42,
q: 'dune',
timeZone: 'UTC',
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
expect(bookRepo.findIdsByWhere).toHaveBeenCalledWith('where-clause');
});
it('restricts query selections to the requested accessible library', async () => {
const { service, bookRepo, libraryService, queryBuilder } = makeService();
const user = makeUser({ id: 7 });
libraryService.findAll.mockResolvedValue([{ id: 5 }, { id: 9 }]);
queryBuilder.buildWhere.mockReturnValue('library-where');
bookRepo.findIdsByWhere.mockResolvedValue([22]);
await expect(service.resolveSelectionToIds({ query: { libraryId: 9 } }, user)).resolves.toEqual([22]);
expect(queryBuilder.buildWhere).toHaveBeenCalledWith(undefined, {
accessibleLibraryIds: [9],
implicitLibraryId: 9,
userId: 7,
q: undefined,
timeZone: 'UTC',
contentFilters: EMPTY_CONTENT_FILTER_RULES,
});
expect(bookRepo.findIdsByWhere).toHaveBeenCalledWith('library-where');
});
it('rejects query selections for inaccessible libraries', async () => {
const { service, libraryService, queryBuilder, bookRepo } = makeService();
libraryService.findAll.mockResolvedValue([{ id: 5 }]);
await expect(service.resolveSelectionToIds({ query: { libraryId: 9 } }, makeUser())).rejects.toThrow(ForbiddenException);
expect(queryBuilder.buildWhere).not.toHaveBeenCalled();
expect(bookRepo.findIdsByWhere).not.toHaveBeenCalled();
});
it('throws when neither bookIds nor query is provided', async () => {
const { service } = makeService();
await expect(service.resolveSelectionToIds({}, makeUser())).rejects.toThrow(BadRequestException);
});
});
describe('writeAndRename', () => {
it('throws NotFoundException when book does not exist', async () => {
const { service, bookRepo } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(null);
await expect(service.writeAndRename(999, makeUser())).rejects.toThrow(NotFoundException);
});
it('cancels pending write and rename before executing', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: [], durationMs: 0 });
fileRenameService.performRename.mockResolvedValue({ status: 'skipped', durationMs: 0, reason: 'path unchanged' });
await service.writeAndRename(10, makeUser());
expect(fileWriteService.cancelPendingWrite).toHaveBeenCalledWith(10);
expect(fileRenameService.cancelPendingRename).toHaveBeenCalledWith(10);
});
it('calls writeToFile with force=true and suppressNotification=true', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: ['title'], durationMs: 5 });
fileRenameService.performRename.mockResolvedValue({ status: 'skipped', durationMs: 0, reason: 'path unchanged' });
await service.writeAndRename(10, makeUser({ id: 7 }));
expect(fileWriteService.writeToFile).toHaveBeenCalledWith(10, 'sync', 7, false, true, true);
});
it('calls performRename with force=true and suppressNotification=true', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: [], durationMs: 0 });
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10, oldPath: '/a', newPath: '/b' });
await service.writeAndRename(10, makeUser({ id: 7 }));
expect(fileRenameService.performRename).toHaveBeenCalledWith(10, 7, true, true);
});
it('returns write and rename results combined', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: ['title', 'authors'], durationMs: 20 });
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10, oldPath: '/old', newPath: '/new' });
fileWriteService.findLibraryWriteSettingsForBook.mockResolvedValue({ fileWriteEnabled: true, fileRenameEnabled: false });
const result = await service.writeAndRename(10, makeUser());
expect(result.write).toEqual({ status: 'success', fieldsWritten: ['title', 'authors'], durationMs: 20 });
expect(result.rename).toEqual({ status: 'success', durationMs: 10, oldPath: '/old', newPath: '/new' });
});
it('includes libraryAutoWriteEnabled and libraryAutoRenameEnabled from settings', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: [], durationMs: 0 });
fileRenameService.performRename.mockResolvedValue({ status: 'skipped', durationMs: 0, reason: 'path unchanged' });
fileWriteService.findLibraryWriteSettingsForBook.mockResolvedValue({ fileWriteEnabled: true, fileRenameEnabled: true });
const result = await service.writeAndRename(10, makeUser());
expect(result.libraryAutoWriteEnabled).toBe(true);
expect(result.libraryAutoRenameEnabled).toBe(true);
});
it('returns write=failed but still runs rename when writeToFile throws', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockRejectedValue(new Error('disk error'));
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 5, oldPath: '/old', newPath: '/new' });
const result = await service.writeAndRename(10, makeUser());
expect(result.write.status).toBe('failed');
expect(result.write.reason).toBe('disk error');
expect(result.rename.status).toBe('success');
});
it('returns rename=failed but reports write result when performRename throws', async () => {
const { service, bookRepo, fileWriteService, fileRenameService } = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: ['title'], durationMs: 5 });
fileRenameService.performRename.mockRejectedValue(new Error('rename crashed'));
const result = await service.writeAndRename(10, makeUser());
expect(result.write.status).toBe('success');
expect(result.rename.status).toBe('failed');
expect(result.rename.reason).toBe('rename crashed');
});
it('returns skipped write when fileWriteService is null', async () => {
const {
bookRepo,
libraryService,
queryBuilder,
metadataService,
scoreService,
pipeline,
config,
appSettings,
userBookStatusService,
userBookNoteService,
narratorService,
comicMetadataService,
customMetadataService,
bookMetadataLockService,
embedder,
fileRenameService,
achievementEvents,
} = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileRenameService.performRename.mockResolvedValue({ status: 'skipped', durationMs: 0, reason: 'disabled' });
const serviceWithoutFileWrite = new BookService(
bookRepo as never,
libraryService as never,
queryBuilder as never,
metadataService as never,
scoreService as never,
pipeline as never,
config as never,
appSettings as never,
userBookStatusService as never,
userBookNoteService as never,
narratorService as never,
comicMetadataService as never,
customMetadataService as never,
bookMetadataLockService as never,
embedder as never,
null as never,
fileRenameService as never,
achievementEvents as never,
);
const result = await serviceWithoutFileWrite.writeAndRename(10, makeUser());
expect(result.write.status).toBe('skipped');
expect(result.write.reason).toContain('unavailable');
});
it('returns skipped rename when fileRenameService is null', async () => {
const {
bookRepo,
libraryService,
queryBuilder,
metadataService,
scoreService,
pipeline,
config,
appSettings,
userBookStatusService,
userBookNoteService,
narratorService,
comicMetadataService,
customMetadataService,
bookMetadataLockService,
embedder,
fileWriteService,
achievementEvents,
} = makeService();
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
fileWriteService.writeToFile.mockResolvedValue({ status: 'success', fieldsWritten: [], durationMs: 0 });
const serviceWithoutRename = new BookService(
bookRepo as never,
libraryService as never,
queryBuilder as never,
metadataService as never,
scoreService as never,
pipeline as never,
config as never,
appSettings as never,
userBookStatusService as never,
userBookNoteService as never,
narratorService as never,
comicMetadataService as never,
customMetadataService as never,
bookMetadataLockService as never,
embedder as never,
fileWriteService as never,
null as never,
achievementEvents as never,
);
const result = await serviceWithoutRename.writeAndRename(10, makeUser());
expect(result.rename.status).toBe('skipped');
expect(result.rename.reason).toContain('unavailable');
});
});
describe('renameFile', () => {
it('renames file on disk and updates db', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = {
absolutePath: '/library/Author/old.epub',
relPath: 'Author/old.epub',
libraryFolderPath: '/library',
filename: 'old.epub',
format: 'epub',
role: 'content',
bookId: 10,
libraryId: 1,
};
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
vi.mocked(rename).mockResolvedValue(undefined);
bookRepo.updateBookFile = vi.fn().mockResolvedValue(undefined);
await service.renameFile(fileId, { filename: 'new.epub' }, user);
expect(rename).toHaveBeenCalledWith('/library/Author/old.epub', '/library/Author/new.epub');
expect(bookRepo.updateBookFile).toHaveBeenCalledWith(fileId, {
absolutePath: '/library/Author/new.epub',
relPath: 'Author/new.epub',
});
});
it('throws BadRequestException for invalid filename', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = {
absolutePath: '/path/to/old.epub',
libraryFolderPath: '/path/to',
filename: 'old.epub',
format: 'epub',
role: 'content',
bookId: 10,
libraryId: 1,
};
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
await expect(service.renameFile(fileId, { filename: '../new.epub' }, user)).rejects.toThrow(BadRequestException);
await expect(service.renameFile(fileId, { filename: 'dir/new.epub' }, user)).rejects.toThrow(BadRequestException);
await expect(service.renameFile(fileId, { filename: 'bad\0name.epub' }, user)).rejects.toThrow(BadRequestException);
await expect(service.renameFile(fileId, { filename: `${'a'.repeat(256)}.epub` }, user)).rejects.toThrow(BadRequestException);
expect(rename).not.toHaveBeenCalled();
});
it('only updates db if filename is not provided or unchanged', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = {
absolutePath: '/path/to/old.epub',
libraryFolderPath: '/path/to',
filename: 'old.epub',
format: 'epub',
role: 'content',
bookId: 10,
libraryId: 1,
};
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
vi.mocked(rename).mockResolvedValue(undefined);
bookRepo.updateBookFile = vi.fn().mockResolvedValue(undefined);
await service.renameFile(fileId, { filename: 'old.epub' }, user);
expect(rename).not.toHaveBeenCalled();
expect(bookRepo.updateBookFile).toHaveBeenCalledWith(fileId, {
absolutePath: undefined,
relPath: undefined,
});
});
});
describe('deleteFile', () => {
it('deletes file on disk and updates db and marks missing if last file', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = { absolutePath: '/path/to/old.epub', bookId: 10, libraryId: 1 };
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
vi.mocked(rm).mockResolvedValue(undefined);
bookRepo.deleteBookFile = vi.fn().mockResolvedValue(undefined);
bookRepo.findFilesForBook = vi.fn().mockResolvedValue([]);
bookRepo.findBookBase = vi.fn().mockResolvedValue({ id: 10, primaryFileId: 100 });
bookRepo.updateBookPrimaryFile = vi.fn().mockResolvedValue(undefined);
await service.deleteFile(fileId, user);
expect(rm).toHaveBeenCalledWith('/path/to/old.epub', { force: true });
expect(bookRepo.deleteBookFile).toHaveBeenCalledWith(fileId);
expect(bookRepo.updateBookPrimaryFile).toHaveBeenCalledWith(10, null);
});
it('elects new primary file if deleted file was primary and other files exist', async () => {
const { service, bookRepo, libraryService } = makeService();
const user = makeUser({ id: 1 });
const fileId = 100;
const file = { absolutePath: '/path/to/old.epub', bookId: 10, libraryId: 1 };
bookRepo.findFileById = vi.fn().mockResolvedValue(file);
libraryService.checkLibraryAccess = vi.fn().mockResolvedValue(true);
vi.mocked(rm).mockResolvedValue(undefined);
bookRepo.deleteBookFile = vi.fn().mockResolvedValue(undefined);
bookRepo.findFilesForBook = vi.fn().mockResolvedValue([
{ id: 100, role: 'content' },
{ id: 101, role: 'content' },
]);
bookRepo.findBookBase = vi.fn().mockResolvedValue({ id: 10, primaryFileId: 100 });
bookRepo.updateBookPrimaryFile = vi.fn().mockResolvedValue(undefined);
await service.deleteFile(fileId, user);
expect(rm).toHaveBeenCalledWith('/path/to/old.epub', { force: true });
expect(bookRepo.deleteBookFile).toHaveBeenCalledWith(fileId);
expect(bookRepo.updateBookPrimaryFile).toHaveBeenCalledWith(10, 101);
});
});
});