mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
759 lines
27 KiB
TypeScript
759 lines
27 KiB
TypeScript
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import type { MockedFunction } from 'vitest';
|
|
import { access, readdir, rm, stat } from 'fs/promises';
|
|
|
|
import type { RequestUser } from '../../common/types/request-user';
|
|
import { MetadataProviderKey } from '@projectx/types';
|
|
import { BookService } from './book.service';
|
|
|
|
vi.mock('fs/promises', async () => {
|
|
const actual = await vi.importActual('fs/promises');
|
|
return {
|
|
...actual,
|
|
access: vi.fn(),
|
|
readdir: vi.fn(),
|
|
rm: vi.fn(),
|
|
stat: 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>;
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function makeService() {
|
|
const bookRepo = {
|
|
findPatternMetadataByBookIds: vi.fn(),
|
|
findLibraryIdsByBookIds: vi.fn(),
|
|
findPrimaryFilesByBookIds: vi.fn(),
|
|
findAllFilesByBookIds: vi.fn(),
|
|
findById: vi.fn(),
|
|
findKoboReadingState: vi.fn(),
|
|
findKoboSnapshotState: vi.fn(),
|
|
findKoboSyncCollectionNamesForBook: vi.fn(),
|
|
findFileById: vi.fn(),
|
|
findLibraryIdByBookId: vi.fn(),
|
|
findProgress: vi.fn(),
|
|
findProgressByBook: vi.fn(),
|
|
upsertProgress: vi.fn(),
|
|
findLatestAudioProgress: vi.fn(),
|
|
updateMetadataFields: vi.fn(),
|
|
deleteByIds: vi.fn(),
|
|
findAllIds: vi.fn(),
|
|
};
|
|
const libraryService = {
|
|
verifyUserAccess: vi.fn().mockResolvedValue(undefined),
|
|
findAll: vi.fn().mockResolvedValue([]),
|
|
};
|
|
const queryBuilder = {
|
|
buildWhere: vi.fn(),
|
|
buildOrderBy: vi.fn(),
|
|
};
|
|
const metadataService = {
|
|
replaceAuthors: vi.fn().mockResolvedValue(undefined),
|
|
replaceGenres: vi.fn().mockResolvedValue(undefined),
|
|
replaceTags: vi.fn().mockResolvedValue(undefined),
|
|
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.booksPath' ? '/tmp/books' : undefined)),
|
|
};
|
|
const appSettings = {
|
|
getDownloadPattern: vi.fn().mockResolvedValue('{originalFilename}'),
|
|
};
|
|
const scoreService = {
|
|
calculateAndSave: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const embedder = {
|
|
embedBook: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const fileWriteService = {
|
|
scheduleWrite: vi.fn(),
|
|
};
|
|
const userBookStatusService = {
|
|
autoUpdate: vi.fn().mockResolvedValue(undefined),
|
|
setManual: vi.fn().mockResolvedValue(undefined),
|
|
findOne: vi.fn().mockResolvedValue(null),
|
|
findByBookIds: vi.fn().mockResolvedValue(new Map()),
|
|
};
|
|
|
|
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,
|
|
{ replaceForBook: vi.fn().mockResolvedValue(undefined) } as never,
|
|
{ upsert: vi.fn().mockResolvedValue(undefined), findByBookId: vi.fn().mockResolvedValue(null) } as never,
|
|
embedder as never,
|
|
fileWriteService as never,
|
|
);
|
|
|
|
return {
|
|
service,
|
|
bookRepo,
|
|
libraryService,
|
|
queryBuilder,
|
|
metadataService,
|
|
pipeline,
|
|
config,
|
|
appSettings,
|
|
userBookStatusService,
|
|
embedder,
|
|
fileWriteService,
|
|
};
|
|
}
|
|
|
|
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 ?? [],
|
|
};
|
|
}
|
|
|
|
describe('BookService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockAccess.mockReset();
|
|
mockReaddir.mockReset();
|
|
mockRm.mockReset();
|
|
mockStat.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');
|
|
});
|
|
});
|
|
|
|
describe('export files', () => {
|
|
it('throws when export is requested with no books', async () => {
|
|
const { service } = makeService();
|
|
|
|
await expect(service.getExportFiles([], makeUser(), false)).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' },
|
|
{ bookId: 2, absolutePath: '/books/b.epub', format: 'epub' },
|
|
]);
|
|
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Duplicate' }), metaRow(2, { title: 'Duplicate' })]);
|
|
|
|
const files = await service.getExportFiles([1, 2], user, false);
|
|
|
|
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 77, false);
|
|
expect(files).toEqual([
|
|
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub' },
|
|
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub' },
|
|
]);
|
|
});
|
|
|
|
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' }]);
|
|
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: '..' })]);
|
|
|
|
const [file] = await service.getExportFiles([1], user, false);
|
|
|
|
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' }]);
|
|
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'One' })]);
|
|
|
|
await service.getExportFiles([1], user, true);
|
|
|
|
expect(bookRepo.findAllFilesByBookIds).toHaveBeenCalledWith([1]);
|
|
expect(bookRepo.findPrimaryFilesByBookIds).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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);
|
|
mockReaddir.mockRejectedValue(new Error('missing'));
|
|
|
|
await expect(service.getCoverPath(9, makeUser())).resolves.toBeNull();
|
|
});
|
|
|
|
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(new Error('nope'));
|
|
await expect(service.getThumbnailPath(9, makeUser())).resolves.toBeNull();
|
|
});
|
|
|
|
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',
|
|
});
|
|
});
|
|
});
|
|
|
|
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: [],
|
|
});
|
|
pipeline.runWithSources.mockResolvedValue({ resolved: { title: 'New Title' }, sources: {}, providerIds: {} });
|
|
const updateSpy = vi.spyOn(service, 'updateMetadata');
|
|
|
|
const result = await service.refreshMetadata(1, true, user);
|
|
|
|
expect(result).toEqual({ title: 'New Title' });
|
|
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,
|
|
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({ id: 1 } 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);
|
|
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.OPEN_LIBRARY]: 'ol-id',
|
|
},
|
|
});
|
|
|
|
const result = await service.refreshMetadata(1, true, user);
|
|
|
|
expect(result).toEqual({
|
|
title: 'Resolved',
|
|
googleBooksId: 'g-id',
|
|
openLibraryId: 'ol-id',
|
|
});
|
|
});
|
|
|
|
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.OPEN_LIBRARY]: 'ol-id',
|
|
},
|
|
});
|
|
|
|
const updateSpy = vi.spyOn(service, 'updateMetadata').mockResolvedValue({ id: 1 } as never);
|
|
|
|
await service.refreshMetadata(1, false, user);
|
|
|
|
expect(updateSpy).toHaveBeenCalledWith(
|
|
1,
|
|
{
|
|
title: 'Resolved',
|
|
googleBooksId: 'g-id',
|
|
openLibraryId: 'ol-id',
|
|
},
|
|
user,
|
|
);
|
|
});
|
|
|
|
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({ id: 1 } as never);
|
|
|
|
await service.refreshMetadata(1, false, user);
|
|
|
|
expect(updateSpy).toHaveBeenCalledWith(
|
|
1,
|
|
{
|
|
title: 'Resolved',
|
|
comicMetadata: {
|
|
issueNumber: '12',
|
|
volumeName: 'Arkham Asylum',
|
|
pencillers: ['Jock'],
|
|
},
|
|
},
|
|
user,
|
|
);
|
|
});
|
|
|
|
it('updateMetadata writes scalar fields, collections, schedules file write, and triggers embedding', async () => {
|
|
const { service, bookRepo, metadataService, embedder, fileWriteService } = makeService();
|
|
const user = makeUser();
|
|
const verifySpy = vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
|
const detailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
|
|
|
|
await service.updateMetadata(
|
|
5,
|
|
{
|
|
title: null,
|
|
rating: 4,
|
|
authors: ['A1', 'A2'],
|
|
genres: ['Sci-Fi'],
|
|
tags: ['favorite'],
|
|
},
|
|
user,
|
|
);
|
|
|
|
expect(verifySpy).toHaveBeenCalledWith(5, user);
|
|
expect(bookRepo.updateMetadataFields).toHaveBeenCalledWith(
|
|
5,
|
|
expect.objectContaining({
|
|
title: null,
|
|
rating: 4,
|
|
updatedAt: expect.any(Date),
|
|
}),
|
|
);
|
|
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(5, [
|
|
{ name: 'A1', sortName: null },
|
|
{ name: 'A2', sortName: null },
|
|
]);
|
|
expect(metadataService.replaceGenres).toHaveBeenCalledWith(5, ['Sci-Fi']);
|
|
expect(metadataService.replaceTags).toHaveBeenCalledWith(5, ['favorite']);
|
|
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(5, 'auto', user.id);
|
|
expect(embedder.embedBook).toHaveBeenCalledWith(5);
|
|
expect(detailSpy).toHaveBeenCalledWith(5, user);
|
|
});
|
|
});
|
|
|
|
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,
|
|
snapshot: null,
|
|
});
|
|
});
|
|
|
|
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',
|
|
progressSyncedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
|
|
});
|
|
bookRepo.findKoboSnapshotState.mockResolvedValue({
|
|
snapshotId: 99,
|
|
snapshotUpdatedAt: new Date('2026-01-03T00:00:00.000Z'),
|
|
synced: true,
|
|
pendingDelete: false,
|
|
isNew: false,
|
|
removedByDevice: false,
|
|
fileHash: 'fhash',
|
|
metadataHash: 'mhash',
|
|
});
|
|
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.snapshot?.snapshotId).toBe(99);
|
|
});
|
|
|
|
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 }]);
|
|
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('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.deleteByIds.mockResolvedValue(undefined);
|
|
mockRm.mockRejectedValue(new Error('cannot delete'));
|
|
|
|
await service.deleteBooks([3, 4], user);
|
|
await Promise.resolve();
|
|
|
|
expect(libraryService.verifyUserAccess).toHaveBeenCalledTimes(2);
|
|
expect(bookRepo.deleteByIds).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();
|
|
});
|
|
});
|
|
|
|
// ── 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.findLatestAudioProgress = vi.fn().mockResolvedValue(progressRow);
|
|
|
|
const result = await service.getAudioProgress(user.id, 10, user);
|
|
expect(result).toBe(progressRow);
|
|
expect(bookRepo.findLatestAudioProgress).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,
|
|
updatedAt: null,
|
|
},
|
|
{
|
|
fileId: 11,
|
|
cfi: 'epubcfi(/6/4)',
|
|
pageNumber: 12,
|
|
percentage: 45,
|
|
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,
|
|
updatedAt: null,
|
|
},
|
|
{
|
|
fileId: 11,
|
|
cfi: 'epubcfi(/6/4)',
|
|
pageNumber: 12,
|
|
percentage: 45,
|
|
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(null);
|
|
|
|
await service.saveProgress(user.id, 7, { percentage: 25, positionSeconds: 900 } as never, user);
|
|
|
|
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 7, null, null, 25, 900);
|
|
});
|
|
|
|
it('passes null positionSeconds when not provided in DTO', 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(null);
|
|
|
|
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
|
|
|
|
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null);
|
|
});
|
|
});
|
|
});
|