mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
2b8917afb6
Let users edit author facts, choose and pin a provider record, and keep later refreshes tied to that record. Preserve compatibility for older clients through additive API fields and a discoverable server feature. Keep server typechecking deterministic by breaking circular Drizzle inference and correcting the nullability mismatches it previously hid. Closes #1464
1230 lines
48 KiB
TypeScript
1230 lines
48 KiB
TypeScript
import { BadRequestException, ForbiddenException, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
|
import { EMPTY_CONTENT_FILTER_RULES } from '@bookorbit/types';
|
|
|
|
import { AUTHOR_ENRICHMENT_REASONS } from './author-enrichment-reasons';
|
|
import { AuthorMetadataPreferenceResolver } from './metadata/author-metadata-preference-resolver';
|
|
import { AuthorsService } from './authors.service';
|
|
|
|
function reqUser(id = 7, superuser = false) {
|
|
return { id, isSuperuser: superuser, permissions: [], contentFilters: undefined } as any;
|
|
}
|
|
|
|
function makeBookRow(id: number, title: string) {
|
|
return {
|
|
id,
|
|
status: 'ready',
|
|
coverAspectRatio: '2/3',
|
|
primaryFileId: null,
|
|
folderPath: `/books/${id}`,
|
|
addedAt: new Date('2026-08-01T00:00:00.000Z'),
|
|
title,
|
|
seriesId: null,
|
|
seriesName: null,
|
|
seriesIndex: null,
|
|
publishedDate: null,
|
|
publishedYear: null,
|
|
language: null,
|
|
rating: null,
|
|
metadataScore: null,
|
|
coverSource: null,
|
|
lockedFields: null,
|
|
subtitle: null,
|
|
publisher: null,
|
|
pageCount: null,
|
|
isbn13: null,
|
|
hardcoverId: null,
|
|
hardcoverEditionId: null,
|
|
};
|
|
}
|
|
|
|
describe('AuthorsService', () => {
|
|
const authorsRepo = {
|
|
findPage: vi.fn(),
|
|
findById: vi.fn(),
|
|
findBookIdsPage: vi.fn(),
|
|
buildBooksWhere: vi.fn(),
|
|
updateAuthorById: vi.fn(),
|
|
findVisibleAuthorIds: vi.fn(),
|
|
countDistinctBooks: vi.fn(),
|
|
findBookIdsByAuthorIds: vi.fn(),
|
|
mergeAuthors: vi.fn(),
|
|
deleteAuthors: vi.fn(),
|
|
findRelatedLibraryIds: vi.fn(),
|
|
findByIdForEnrichment: vi.fn(),
|
|
updateAuthorDescriptionIfEmpty: vi.fn(),
|
|
countAuthors: vi.fn(),
|
|
findLetterCounts: vi.fn(),
|
|
};
|
|
|
|
const bookReadService = {
|
|
findCardsByBookIds: vi.fn(),
|
|
findCardsCollapsed: vi.fn(),
|
|
countWhere: vi.fn(),
|
|
};
|
|
|
|
const libraryService = {
|
|
findAll: vi.fn(),
|
|
findAccessibleLibraryIds: vi.fn(),
|
|
findEditableLibraryIds: vi.fn(),
|
|
};
|
|
|
|
const authorMetadataFetchService = {
|
|
listProviders: vi.fn(),
|
|
search: vi.fn(),
|
|
lookupById: vi.fn(),
|
|
lookupByIdDetailed: vi.fn(),
|
|
stream: vi.fn(),
|
|
quickSearch: vi.fn(),
|
|
};
|
|
|
|
const authorImageStorage = {
|
|
saveFromUrl: vi.fn(),
|
|
saveFromBuffer: vi.fn(),
|
|
getThumbnailPath: vi.fn(),
|
|
getThumbnailUrlIfExists: vi.fn(),
|
|
getImagePath: vi.fn(),
|
|
getImageUrlIfExists: vi.fn(),
|
|
deleteAuthorDir: vi.fn(),
|
|
};
|
|
|
|
const enrichmentExecutor = {
|
|
execute: vi.fn(),
|
|
applyMatch: vi.fn(),
|
|
};
|
|
|
|
const enrichmentOrchestrator = {
|
|
schedule: vi.fn(),
|
|
backfillLinkedAuthors: vi.fn(),
|
|
};
|
|
|
|
const metadataPreferences = {
|
|
getPreferences: vi.fn(),
|
|
};
|
|
const metadataScoreService = {
|
|
calculateAndSaveMany: vi.fn(),
|
|
};
|
|
|
|
let service: AuthorsService;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
service = new AuthorsService(
|
|
authorsRepo as any,
|
|
bookReadService as any,
|
|
libraryService as any,
|
|
metadataPreferences as any,
|
|
authorMetadataFetchService as any,
|
|
authorImageStorage as any,
|
|
enrichmentExecutor as any,
|
|
enrichmentOrchestrator as any,
|
|
metadataScoreService as any,
|
|
{ enrichCardFaces: vi.fn().mockResolvedValue(undefined) } as any,
|
|
);
|
|
libraryService.findAll.mockResolvedValue([{ id: 1 }, { id: 2 }]);
|
|
libraryService.findAccessibleLibraryIds.mockResolvedValue([1, 2]);
|
|
libraryService.findEditableLibraryIds.mockResolvedValue([1, 2]);
|
|
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue(null);
|
|
authorImageStorage.getImageUrlIfExists.mockResolvedValue(null);
|
|
enrichmentOrchestrator.schedule.mockResolvedValue(1);
|
|
enrichmentOrchestrator.backfillLinkedAuthors.mockResolvedValue(8);
|
|
metadataPreferences.getPreferences.mockResolvedValue(new AuthorMetadataPreferenceResolver().getDefaultPreferences());
|
|
metadataScoreService.calculateAndSaveMany.mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('countAll counts authors across the accessible libraries', async () => {
|
|
authorsRepo.countAuthors.mockResolvedValue(1234);
|
|
|
|
await expect(service.countAll(reqUser())).resolves.toBe(1234);
|
|
expect(authorsRepo.countAuthors).toHaveBeenCalledWith({ libraryIds: [1, 2], contentFilters: undefined });
|
|
});
|
|
|
|
it('countAll skips the query when the user has no library access', async () => {
|
|
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
|
|
|
|
await expect(service.countAll(reqUser())).resolves.toBe(0);
|
|
expect(authorsRepo.countAuthors).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('merge rejects when sources do not contain any id different from target', async () => {
|
|
await expect(service.merge(reqUser(7, true), { targetAuthorId: 10, sourceAuthorIds: [10, 10] })).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('merge requires superuser', async () => {
|
|
await expect(service.merge(reqUser(), { targetAuthorId: 10, sourceAuthorIds: [11] })).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('delete requires superuser', async () => {
|
|
await expect(service.delete(reqUser(), { authorIds: [11] })).rejects.toBeInstanceOf(ForbiddenException);
|
|
});
|
|
|
|
it('merge blocks mutation when selected authors are linked to inaccessible libraries', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 999]);
|
|
|
|
await expect(service.merge(reqUser(7, true), { targetAuthorId: 10, sourceAuthorIds: [11] })).rejects.toBeInstanceOf(ForbiddenException);
|
|
expect(authorsRepo.mergeAuthors).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('delete removes authors and returns impacted count', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]);
|
|
authorsRepo.findBookIdsByAuthorIds.mockResolvedValue([1, 2, 3, 4, 5, 6]);
|
|
authorsRepo.deleteAuthors.mockResolvedValue(undefined);
|
|
|
|
const result = await service.delete(reqUser(7, true), { authorIds: [10, 11, 11] });
|
|
|
|
expect(authorsRepo.deleteAuthors).toHaveBeenCalledWith([10, 11]);
|
|
expect(metadataScoreService.calculateAndSaveMany).toHaveBeenCalledWith([1, 2, 3, 4, 5, 6]);
|
|
expect(result.deletedAuthorIds).toEqual([10, 11]);
|
|
expect(result.affectedBookCount).toBe(6);
|
|
});
|
|
|
|
it('merge deduplicates sources, merges, and returns impacted count', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([10, 11, 12]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]);
|
|
authorsRepo.findBookIdsByAuthorIds.mockResolvedValue([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
authorsRepo.mergeAuthors.mockResolvedValue(undefined);
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 10,
|
|
name: 'Target',
|
|
sortName: 'Target',
|
|
description: null,
|
|
bookCount: 3,
|
|
lastAddedAt: null,
|
|
});
|
|
|
|
const result = await service.merge(reqUser(7, true), { targetAuthorId: 10, sourceAuthorIds: [10, 11, 11, 12] });
|
|
|
|
expect(authorsRepo.mergeAuthors).toHaveBeenCalledWith(10, [11, 12]);
|
|
expect(metadataScoreService.calculateAndSaveMany).toHaveBeenCalledWith([1, 2, 3, 4, 5, 6, 7, 8]);
|
|
expect(enrichmentOrchestrator.schedule).toHaveBeenCalledWith(10, AUTHOR_ENRICHMENT_REASONS.AUTHOR_MERGE_TARGET);
|
|
expect(result.mergedAuthorIds).toEqual([11, 12]);
|
|
expect(result.affectedBookCount).toBe(8);
|
|
});
|
|
|
|
it('update rejects authors whose books sit in a library the user can only read', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([20]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1, 2]);
|
|
libraryService.findEditableLibraryIds.mockResolvedValue([1]);
|
|
|
|
await expect(service.update(reqUser(), 20, { name: 'Renamed' })).rejects.toBeInstanceOf(ForbiddenException);
|
|
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('update normalizes author names and blank optional fields', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([20]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorsRepo.updateAuthorById.mockResolvedValue({ id: 20, name: 'Updated', sortName: null, description: 'Bio' });
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 20,
|
|
name: 'Updated',
|
|
sortName: null,
|
|
description: 'Bio',
|
|
bookCount: 4,
|
|
lastAddedAt: null,
|
|
});
|
|
|
|
await service.update(reqUser(), 20, {
|
|
name: ' Updated Author ',
|
|
sortName: ' Author,\tUpdated ',
|
|
description: ' Bio ',
|
|
});
|
|
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(20, {
|
|
name: 'Updated Author',
|
|
sortName: 'Author, Updated',
|
|
description: 'Bio',
|
|
});
|
|
expect(enrichmentOrchestrator.schedule).toHaveBeenCalledWith(20, AUTHOR_ENRICHMENT_REASONS.AUTHOR_RENAME);
|
|
});
|
|
|
|
it('findOne returns not found when author is outside user-accessible libraries', async () => {
|
|
authorsRepo.findById.mockResolvedValue(null);
|
|
await expect(service.findOne(reqUser(), 404)).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('refreshEnrichment fills description when missing and returns updated author', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([20]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
enrichmentExecutor.execute.mockResolvedValue({
|
|
kind: 'done',
|
|
provider: 'audnexus',
|
|
descriptionUpdated: true,
|
|
imageUpdated: false,
|
|
outboundRequests: 2,
|
|
cacheServed: [],
|
|
});
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 20,
|
|
name: 'Jane Doe',
|
|
sortName: 'Doe, Jane',
|
|
description: 'Provider description',
|
|
bookCount: 2,
|
|
lastAddedAt: null,
|
|
});
|
|
|
|
const result = await service.refreshEnrichment(reqUser(), 20);
|
|
|
|
expect(enrichmentExecutor.execute).toHaveBeenCalledWith(
|
|
{
|
|
authorId: 20,
|
|
preferences: expect.objectContaining({ fields: expect.any(Object) }),
|
|
},
|
|
{ policy: 'refresh', allowFallback: true },
|
|
);
|
|
expect(result.description).toBe('Provider description');
|
|
expect(result).not.toHaveProperty('metadataCache');
|
|
});
|
|
|
|
it('refreshEnrichment reports only the providers that fell back to saved answers', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([25]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
const fallback = { provider: 'goodreads', source: 'fallback', stale: true, cachedAt: '2026-08-01T00:00:00.000Z' };
|
|
enrichmentExecutor.execute.mockResolvedValue({
|
|
kind: 'done',
|
|
provider: 'goodreads',
|
|
descriptionUpdated: true,
|
|
imageUpdated: false,
|
|
outboundRequests: 3,
|
|
cacheServed: [{ provider: 'audnexus', source: 'cache', stale: false, cachedAt: '2026-09-01T00:00:00.000Z' }, fallback],
|
|
});
|
|
authorsRepo.findById.mockResolvedValue({ id: 25, name: 'Jane Doe', sortName: null, description: 'Bio', bookCount: 1, lastAddedAt: null });
|
|
|
|
const result = await service.refreshEnrichment(reqUser(), 25);
|
|
|
|
expect(result).toEqual(expect.objectContaining({ id: 25, metadataCache: { fallbackProviders: [fallback] } }));
|
|
});
|
|
|
|
it('refreshEnrichment stores fetched author image on disk when provider returns one', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([21]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
enrichmentExecutor.execute.mockResolvedValue({
|
|
kind: 'done',
|
|
provider: 'audnexus',
|
|
descriptionUpdated: false,
|
|
imageUpdated: true,
|
|
outboundRequests: 2,
|
|
cacheServed: [],
|
|
});
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 21,
|
|
name: 'John Doe',
|
|
sortName: 'Doe, John',
|
|
description: 'Existing bio',
|
|
bookCount: 4,
|
|
lastAddedAt: null,
|
|
});
|
|
|
|
await service.refreshEnrichment(reqUser(), 21);
|
|
|
|
expect(enrichmentExecutor.execute).toHaveBeenCalledWith(
|
|
{
|
|
authorId: 21,
|
|
preferences: expect.objectContaining({ fields: expect.any(Object) }),
|
|
},
|
|
{ policy: 'refresh', allowFallback: true },
|
|
);
|
|
});
|
|
|
|
it('refreshEnrichment throws when provider fails so callers can surface failure', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([22]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
enrichmentExecutor.execute.mockResolvedValue({
|
|
kind: 'failed',
|
|
provider: 'audnexus',
|
|
message: 'upstream timeout',
|
|
httpStatus: 504,
|
|
retryAfterMs: 5_000,
|
|
transient: true,
|
|
descriptionUpdated: false,
|
|
imageUpdated: false,
|
|
});
|
|
|
|
await expect(service.refreshEnrichment(reqUser(), 22)).rejects.toThrow('Author enrichment failed');
|
|
});
|
|
|
|
it('findAll returns an empty page when user has no accessible libraries', async () => {
|
|
libraryService.findAll.mockResolvedValue([]);
|
|
|
|
const page = await service.findAll(reqUser(), {});
|
|
|
|
expect(page).toEqual({ items: [], total: 0, page: 0, size: 50 });
|
|
expect(authorsRepo.findPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('findAll applies defaults and appends thumbnail urls', async () => {
|
|
authorsRepo.findPage.mockResolvedValue({
|
|
items: [
|
|
{
|
|
id: 10,
|
|
name: 'Alpha',
|
|
sortName: null,
|
|
description: null,
|
|
bookCount: 3,
|
|
lastAddedAt: null,
|
|
},
|
|
],
|
|
total: 1,
|
|
page: 0,
|
|
size: 50,
|
|
});
|
|
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue('https://cdn.example.com/a10-thumb.jpg');
|
|
|
|
const page = await service.findAll(reqUser(), {});
|
|
|
|
expect(authorsRepo.findPage).toHaveBeenCalledWith({
|
|
q: undefined,
|
|
page: 0,
|
|
size: 50,
|
|
sort: 'name',
|
|
order: 'asc',
|
|
libraryIds: [1, 2],
|
|
hasPhoto: undefined,
|
|
minBookCount: undefined,
|
|
contentFilters: undefined,
|
|
});
|
|
expect(page.items[0]).toEqual(
|
|
expect.objectContaining({
|
|
id: 10,
|
|
name: 'Alpha',
|
|
imageUrl: 'https://cdn.example.com/a10-thumb.jpg',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('findAll rejects excessively deep offsets', async () => {
|
|
await expect(service.findAll(reqUser(), { page: 2_000_000, size: 100 })).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('findBooks returns empty page when user has no accessible libraries', async () => {
|
|
libraryService.findAll.mockResolvedValue([]);
|
|
|
|
const page = await service.findBooks(reqUser(), 99, {});
|
|
|
|
expect(page).toEqual({ items: [], total: 0, bookTotal: 0, page: 0, size: 50 });
|
|
expect(authorsRepo.findById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('findBooks throws not found when the author is not visible', async () => {
|
|
authorsRepo.findById.mockResolvedValue(null);
|
|
|
|
await expect(service.findBooks(reqUser(), 99, {})).rejects.toBeInstanceOf(NotFoundException);
|
|
});
|
|
|
|
it('findBooks rejects when pagination window is too deep', async () => {
|
|
await expect(service.findBooks(reqUser(), 1, { page: 2_000_000, size: 100 })).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('findBooks returns empty items when author has no books in the given libraries', async () => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', bookCount: 0 });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 50 });
|
|
|
|
const result = await service.findBooks(reqUser(), 10, {});
|
|
|
|
expect(result).toEqual({ items: [], total: 0, bookTotal: 0, page: 0, size: 50 });
|
|
expect(bookReadService.findCardsByBookIds).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe('findBooks with collapseSeries', () => {
|
|
const AUTHOR_WHERE = { fake: 'where' } as any;
|
|
|
|
function collapsedRow(id: number, seriesName: string, bookCount: number) {
|
|
return {
|
|
...makeBookRow(id, `${seriesName} #1`),
|
|
seriesId: id * 100,
|
|
seriesName,
|
|
bookCount,
|
|
readCount: 0,
|
|
coverBookIds: [id],
|
|
coverUpdatedAtByBookId: null,
|
|
seriesLatestAddedAt: new Date('2026-08-01T00:00:00.000Z'),
|
|
firstVolumeBookId: id,
|
|
latestVolumeBookId: id,
|
|
firstUnreadBookId: id,
|
|
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
|
|
};
|
|
}
|
|
|
|
function emptyCollapsedResult(rows: ReturnType<typeof collapsedRow>[], total: number, bookTotal = total) {
|
|
return {
|
|
rows,
|
|
authorRows: [],
|
|
fileRows: [],
|
|
genreRows: [],
|
|
tagRows: [],
|
|
progressRows: [],
|
|
statusRows: [],
|
|
narratorRows: [],
|
|
seriesMembershipRows: [],
|
|
total,
|
|
bookTotal,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', bookCount: 200 });
|
|
authorsRepo.buildBooksWhere.mockReturnValue(AUTHOR_WHERE);
|
|
});
|
|
|
|
it('routes to the collapsed query instead of the flat one', async () => {
|
|
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([collapsedRow(1, 'Discworld', 41)], 30, 200));
|
|
|
|
const result = await service.findBooks(reqUser(), 10, { collapseSeries: true });
|
|
|
|
expect(authorsRepo.findBookIdsPage).not.toHaveBeenCalled();
|
|
expect(bookReadService.findCardsCollapsed).toHaveBeenCalledWith(
|
|
expect.objectContaining({ where: AUTHOR_WHERE, limit: 50, offset: 0, userId: 7 }),
|
|
);
|
|
expect(result.items[0]!.collapsedSeries).toEqual(expect.objectContaining({ bookCount: 41 }));
|
|
});
|
|
|
|
it('reports rows as total and books as bookTotal', async () => {
|
|
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([collapsedRow(1, 'Discworld', 41)], 30, 200));
|
|
|
|
const result = await service.findBooks(reqUser(), 10, { collapseSeries: true });
|
|
|
|
expect(result.total).toBe(30);
|
|
expect(result.bookTotal).toBe(200);
|
|
});
|
|
|
|
it('uses counts returned with the collapsed rows without a second query', async () => {
|
|
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([], 30, 200));
|
|
|
|
const result = await service.findBooks(reqUser(), 10, { collapseSeries: true, page: 20 });
|
|
|
|
expect(result).toEqual({ items: [], total: 30, bookTotal: 200, page: 20, size: 50 });
|
|
expect(bookReadService.countWhere).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('maps the author sort onto the collapsed sort spec', async () => {
|
|
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([], 0));
|
|
|
|
await service.findBooks(reqUser(), 10, { collapseSeries: true, sort: 'title', order: 'asc', page: 2, size: 20 });
|
|
|
|
expect(bookReadService.findCardsCollapsed).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sort: [{ field: 'title', dir: 'asc' }], limit: 20, offset: 40 }),
|
|
);
|
|
});
|
|
|
|
it('scopes the collapsed query by author id, libraries, and content filters', async () => {
|
|
bookReadService.findCardsCollapsed.mockResolvedValue(emptyCollapsedResult([], 0));
|
|
libraryService.findAll.mockResolvedValue([{ id: 3 }]);
|
|
|
|
await service.findBooks({ ...reqUser(), contentFilters: EMPTY_CONTENT_FILTER_RULES }, 10, { collapseSeries: true });
|
|
|
|
expect(authorsRepo.buildBooksWhere).toHaveBeenCalledWith({
|
|
authorId: 10,
|
|
libraryIds: [3],
|
|
contentFilters: EMPTY_CONTENT_FILTER_RULES,
|
|
});
|
|
});
|
|
|
|
it('leaves the flat path untouched when the flag is absent', async () => {
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 7, page: 0, size: 50 });
|
|
|
|
const result = await service.findBooks(reqUser(), 10, {});
|
|
|
|
expect(bookReadService.findCardsCollapsed).not.toHaveBeenCalled();
|
|
// Nothing is folded, so the two counts are the same number.
|
|
expect(result.total).toBe(7);
|
|
expect(result.bookTotal).toBe(7);
|
|
});
|
|
});
|
|
|
|
it('findBooks passes sort, order, size, and libraryId to findBookIdsPage', async () => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', bookCount: 3 });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 10 });
|
|
|
|
await service.findBooks(reqUser(), 10, { sort: 'title', order: 'asc', size: 10, page: 0, libraryId: 2 });
|
|
|
|
expect(authorsRepo.findBookIdsPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
authorId: 10,
|
|
sort: 'title',
|
|
order: 'asc',
|
|
size: 10,
|
|
page: 0,
|
|
libraryIds: expect.arrayContaining([2]),
|
|
contentFilters: undefined,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('findBooks assembles and returns books in bookIds page order', async () => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 5, name: 'Author', bookCount: 3 });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [3, 1, 2], total: 3, page: 0, size: 50 });
|
|
bookReadService.findCardsByBookIds.mockResolvedValue({
|
|
rows: [makeBookRow(1, 'A'), makeBookRow(2, 'B'), makeBookRow(3, 'C')],
|
|
authorRows: [],
|
|
fileRows: [],
|
|
genreRows: [],
|
|
progressRows: [],
|
|
statusRows: [],
|
|
narratorRows: [],
|
|
tagRows: [],
|
|
seriesMembershipRows: [],
|
|
total: 3,
|
|
});
|
|
|
|
const result = await service.findBooks(reqUser(), 5, {});
|
|
|
|
expect(bookReadService.findCardsByBookIds).toHaveBeenCalledWith([3, 1, 2], 7);
|
|
expect(result.total).toBe(3);
|
|
expect(result.items.map((b) => b.id)).toEqual([3, 1, 2]);
|
|
});
|
|
|
|
it('findBooks preserves per-user status and all card enrichments', async () => {
|
|
const updatedAt = new Date('2026-08-05T12:00:00.000Z');
|
|
const startedAt = new Date('2026-08-01T08:30:00.000Z');
|
|
const expectedFinishedAt = new Date('2026-08-04T18:45:00.000Z');
|
|
|
|
authorsRepo.findById.mockResolvedValue({ id: 5, name: 'Author', bookCount: 1 });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [11], total: 1, page: 0, size: 50 });
|
|
bookReadService.findCardsByBookIds.mockResolvedValue({
|
|
rows: [{ ...makeBookRow(11, 'Enriched Book'), primaryFileId: 101 }],
|
|
authorRows: [{ bookId: 11, name: 'Author' }],
|
|
fileRows: [{ bookId: 11, id: 101, format: 'epub', role: 'content', sizeBytes: 2048 }],
|
|
genreRows: [{ bookId: 11, name: 'Fantasy' }],
|
|
progressRows: [{ bookFileId: 101, percentage: 100 }],
|
|
statusRows: [
|
|
{
|
|
bookId: 11,
|
|
status: 'read',
|
|
source: 'manual',
|
|
startedAt,
|
|
finishedAt: expectedFinishedAt,
|
|
updatedAt,
|
|
},
|
|
],
|
|
narratorRows: [{ bookId: 11, name: 'Narrator' }],
|
|
tagRows: [{ bookId: 11, name: 'Favorite' }],
|
|
seriesMembershipRows: [
|
|
{
|
|
bookId: 11,
|
|
seriesId: 4,
|
|
seriesName: 'Saga',
|
|
seriesIndex: 2,
|
|
displayOrder: 0,
|
|
expectedBookCount: 3,
|
|
},
|
|
],
|
|
total: 1,
|
|
});
|
|
|
|
const result = await service.findBooks(reqUser(), 5, {});
|
|
|
|
expect(result.items).toHaveLength(1);
|
|
expect(result.items[0]).toMatchObject({
|
|
id: 11,
|
|
authors: ['Author'],
|
|
files: [{ id: 101, format: 'epub', role: 'primary', sizeBytes: 2048 }],
|
|
genres: ['Fantasy'],
|
|
readingProgress: 100,
|
|
readStatus: {
|
|
status: 'read',
|
|
source: 'manual',
|
|
startedAt: startedAt.toISOString(),
|
|
finishedAt: expectedFinishedAt.toISOString(),
|
|
updatedAt: updatedAt.toISOString(),
|
|
},
|
|
narrators: ['Narrator'],
|
|
tags: ['Favorite'],
|
|
seriesMemberships: [
|
|
{
|
|
seriesId: 4,
|
|
seriesName: 'Saga',
|
|
seriesIndex: 2,
|
|
displayOrder: 0,
|
|
expectedBookCount: 3,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('findBooks uses defaults when dto fields are omitted', async () => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 7, name: 'Author', bookCount: 0 });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 50 });
|
|
|
|
await service.findBooks(reqUser(), 7, {});
|
|
|
|
expect(authorsRepo.findBookIdsPage).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sort: 'addedAt', order: 'desc', page: 0, size: 50, contentFilters: undefined }),
|
|
);
|
|
});
|
|
|
|
it('findBooks scopes results to user-accessible libraries only', async () => {
|
|
libraryService.findAll.mockResolvedValue([{ id: 3 }]);
|
|
authorsRepo.findById.mockResolvedValue({ id: 8, name: 'Author', bookCount: 2 });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 50 });
|
|
|
|
await service.findBooks(reqUser(), 8, {});
|
|
|
|
expect(authorsRepo.findBookIdsPage).toHaveBeenCalledWith(expect.objectContaining({ libraryIds: [3], contentFilters: undefined }));
|
|
});
|
|
|
|
describe('content filter enforcement', () => {
|
|
it('passes contentFilters to findPage for non-superuser', async () => {
|
|
authorsRepo.findPage.mockResolvedValue({ items: [], total: 0, page: 0, size: 50 });
|
|
|
|
await service.findAll({ ...reqUser(), contentFilters: EMPTY_CONTENT_FILTER_RULES }, {});
|
|
|
|
expect(authorsRepo.findPage).toHaveBeenCalledWith(expect.objectContaining({ contentFilters: EMPTY_CONTENT_FILTER_RULES }));
|
|
});
|
|
|
|
it('passes undefined to findPage for superuser', async () => {
|
|
authorsRepo.findPage.mockResolvedValue({ items: [], total: 0, page: 0, size: 50 });
|
|
|
|
await service.findAll({ ...reqUser(7, true), contentFilters: EMPTY_CONTENT_FILTER_RULES }, {});
|
|
|
|
expect(authorsRepo.findPage).toHaveBeenCalledWith(expect.objectContaining({ contentFilters: undefined }));
|
|
});
|
|
|
|
it('passes contentFilters to findById and findBookIdsPage for non-superuser', async () => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', sortName: null, description: null, bookCount: 0, lastAddedAt: null });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 50 });
|
|
|
|
await service.findBooks({ ...reqUser(), contentFilters: EMPTY_CONTENT_FILTER_RULES }, 10, {});
|
|
|
|
expect(authorsRepo.findById).toHaveBeenCalledWith(10, [1, 2], EMPTY_CONTENT_FILTER_RULES);
|
|
expect(authorsRepo.findBookIdsPage).toHaveBeenCalledWith(expect.objectContaining({ contentFilters: EMPTY_CONTENT_FILTER_RULES }));
|
|
});
|
|
|
|
it('passes undefined to findById and findBookIdsPage for superuser', async () => {
|
|
authorsRepo.findById.mockResolvedValue({ id: 10, name: 'Author', sortName: null, description: null, bookCount: 0, lastAddedAt: null });
|
|
authorsRepo.findBookIdsPage.mockResolvedValue({ bookIds: [], total: 0, page: 0, size: 50 });
|
|
|
|
await service.findBooks({ ...reqUser(7, true), contentFilters: EMPTY_CONTENT_FILTER_RULES }, 10, {});
|
|
|
|
expect(authorsRepo.findById).toHaveBeenCalledWith(10, [1, 2], undefined);
|
|
expect(authorsRepo.findBookIdsPage).toHaveBeenCalledWith(expect.objectContaining({ contentFilters: undefined }));
|
|
});
|
|
});
|
|
|
|
it('returns metadata providers directly from the metadata fetch service', () => {
|
|
authorMetadataFetchService.listProviders.mockReturnValue([{ key: 'audnexus', label: 'Audnexus', identifiable: true }]);
|
|
|
|
expect(service.listMetadataProviders()).toEqual([{ key: 'audnexus', label: 'Audnexus', identifiable: true }]);
|
|
});
|
|
|
|
it('searchMetadata forwards query and provider filters', async () => {
|
|
authorMetadataFetchService.search.mockResolvedValue([{ provider: 'audnexus', providerId: 'A1', name: 'John Smith' }]);
|
|
|
|
await expect(service.searchMetadata({ q: 'John Smith', region: 'us', limit: 2, providers: ['audnexus'] })).resolves.toEqual([
|
|
{ provider: 'audnexus', providerId: 'A1', name: 'John Smith' },
|
|
]);
|
|
expect(authorMetadataFetchService.search).toHaveBeenCalledWith(
|
|
{ name: 'John Smith', region: 'us', limit: 2 },
|
|
{ keys: ['audnexus'], directive: { policy: 'default', allowFallback: true } },
|
|
);
|
|
});
|
|
|
|
it('searchMetadata goes live when the client asks to refresh', async () => {
|
|
authorMetadataFetchService.search.mockResolvedValue([]);
|
|
|
|
await service.searchMetadata({ q: 'John Smith', cache: 'refresh' });
|
|
|
|
expect(authorMetadataFetchService.search).toHaveBeenCalledWith(expect.anything(), {
|
|
keys: undefined,
|
|
directive: { policy: 'refresh', allowFallback: true },
|
|
});
|
|
});
|
|
|
|
it('searchMetadata returns cache provenance on candidates unchanged', async () => {
|
|
const cached = { provider: 'audnexus', providerId: 'A1', name: 'John Smith', cache: { fetchedAt: '2026-09-01T00:00:00.000Z', stale: false } };
|
|
authorMetadataFetchService.search.mockResolvedValue([cached]);
|
|
|
|
await expect(service.searchMetadata({ q: 'John Smith' })).resolves.toEqual([cached]);
|
|
});
|
|
|
|
it('lookupMetadata forwards provider key and id', async () => {
|
|
authorMetadataFetchService.lookupById.mockResolvedValue({ provider: 'audnexus', providerId: 'A1', name: 'John Smith' });
|
|
|
|
await expect(service.lookupMetadata({ provider: 'audnexus', id: 'A1', region: 'ca' })).resolves.toEqual({
|
|
provider: 'audnexus',
|
|
providerId: 'A1',
|
|
name: 'John Smith',
|
|
});
|
|
expect(authorMetadataFetchService.lookupById).toHaveBeenCalledWith('audnexus', 'A1', 'ca', {
|
|
directive: { policy: 'default', allowFallback: true },
|
|
});
|
|
|
|
await service.lookupMetadata({ provider: 'audnexus', id: 'A1', cache: 'refresh' });
|
|
expect(authorMetadataFetchService.lookupById).toHaveBeenLastCalledWith('audnexus', 'A1', undefined, {
|
|
directive: { policy: 'refresh', allowFallback: true },
|
|
});
|
|
});
|
|
|
|
it('streamMetadata proxies through provider stream options', () => {
|
|
const mockStream = Symbol('stream');
|
|
authorMetadataFetchService.stream.mockReturnValue(mockStream as any);
|
|
|
|
const stream = service.streamMetadata({ q: 'Jane', region: 'us', limit: 5, providers: ['audnexus'] });
|
|
|
|
expect(stream).toBe(mockStream);
|
|
expect(authorMetadataFetchService.stream).toHaveBeenCalledWith(
|
|
{ name: 'Jane', region: 'us', limit: 5 },
|
|
{ keys: ['audnexus'], directive: { policy: 'default', allowFallback: true } },
|
|
);
|
|
|
|
service.streamMetadata({ q: 'Jane', cache: 'refresh' });
|
|
expect(authorMetadataFetchService.stream).toHaveBeenLastCalledWith(expect.anything(), {
|
|
keys: undefined,
|
|
directive: { policy: 'refresh', allowFallback: true },
|
|
});
|
|
});
|
|
|
|
it('update returns current detail when no mutable fields are provided', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([30]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 30,
|
|
name: 'Current',
|
|
sortName: null,
|
|
description: null,
|
|
bookCount: 1,
|
|
lastAddedAt: null,
|
|
});
|
|
|
|
const result = await service.update(reqUser(), 30, {});
|
|
|
|
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
|
|
expect(result).toEqual(expect.objectContaining({ id: 30, name: 'Current' }));
|
|
});
|
|
|
|
it('update rejects blank names', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([31]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
|
|
await expect(service.update(reqUser(), 31, { name: ' ' })).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
describe('author facts', () => {
|
|
beforeEach(() => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([32]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorsRepo.updateAuthorById.mockResolvedValue({ id: 32 });
|
|
authorsRepo.findById.mockResolvedValue({ id: 32, name: 'John Reid', sortName: null, description: null, bookCount: 3, lastAddedAt: null });
|
|
});
|
|
|
|
it('update writes facts, takes the year from a full date and tidies lists', async () => {
|
|
await service.update(reqUser(), 32, {
|
|
website: ' https://johnreid.example ',
|
|
birthDate: '1965-03-14',
|
|
birthYear: 1900,
|
|
deathYear: 2020,
|
|
genres: [' Thriller ', 'thriller', '', 'Military fiction'],
|
|
influences: [' '],
|
|
});
|
|
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(32, {
|
|
website: 'https://johnreid.example',
|
|
birthDate: '1965-03-14',
|
|
birthYear: 1965,
|
|
deathDate: null,
|
|
deathYear: 2020,
|
|
genres: ['Thriller', 'Military fiction'],
|
|
influences: null,
|
|
});
|
|
expect(enrichmentOrchestrator.schedule).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('update clears facts sent as null and keeps a year sent beside a cleared date', async () => {
|
|
await service.update(reqUser(), 32, { website: null, birthDate: null, birthYear: 1965, deathDate: null, genres: null });
|
|
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(32, {
|
|
website: null,
|
|
birthDate: null,
|
|
birthYear: 1965,
|
|
deathDate: null,
|
|
genres: null,
|
|
});
|
|
});
|
|
|
|
it.each(['1965-02-30', '1965-13-01', '0000-01-01'])('update rejects the impossible date %s', async (birthDate) => {
|
|
await expect(service.update(reqUser(), 32, { birthDate })).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(authorsRepo.updateAuthorById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('update accepts a leap day and a first-century date', async () => {
|
|
await service.update(reqUser(), 32, { birthDate: '2024-02-29', deathDate: '0065-04-12' });
|
|
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(32, {
|
|
birthDate: '2024-02-29',
|
|
birthYear: 2024,
|
|
deathDate: '0065-04-12',
|
|
deathYear: 65,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('metadata match', () => {
|
|
const enrichmentRow = { id: 33, name: 'John Reid', hasPhoto: true, bookCount: 2, metadataProviderPinned: false };
|
|
const candidate = { provider: 'goodreads', providerId: '4567', name: 'John Reid', description: 'Scottish novelist.' };
|
|
|
|
beforeEach(() => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([33]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorsRepo.findByIdForEnrichment.mockResolvedValue(enrichmentRow);
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 33,
|
|
name: 'John Reid',
|
|
sortName: null,
|
|
description: 'Scottish novelist.',
|
|
bookCount: 2,
|
|
lastAddedAt: null,
|
|
metadataProvider: 'goodreads',
|
|
metadataProviderId: '4567',
|
|
metadataProviderPinned: true,
|
|
});
|
|
});
|
|
|
|
it('pins the looked-up person and returns the replaced author', async () => {
|
|
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate, failure: null, cacheServed: [], outboundRequests: 1 });
|
|
enrichmentExecutor.applyMatch.mockResolvedValue({ kind: 'applied', imageUpdated: true, fieldsUpdated: ['description', 'photo'], downloads: 1 });
|
|
|
|
const result = await service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any);
|
|
|
|
expect(authorMetadataFetchService.lookupByIdDetailed).toHaveBeenCalledWith('goodreads', '4567', 'us', {
|
|
directive: { policy: 'refresh', allowFallback: true },
|
|
});
|
|
expect(enrichmentExecutor.applyMatch).toHaveBeenCalledWith(enrichmentRow, expect.objectContaining({ fields: expect.any(Object) }), candidate);
|
|
expect(result).toEqual(expect.objectContaining({ id: 33, metadataProviderPinned: true }));
|
|
expect(result).not.toHaveProperty('metadataCache');
|
|
});
|
|
|
|
it('reports a cache fallback the same way a refresh does', async () => {
|
|
const served = { provider: 'goodreads', source: 'fallback', fetchedAt: '2026-10-01T00:00:00.000Z' };
|
|
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate, failure: null, cacheServed: [served], outboundRequests: 1 });
|
|
enrichmentExecutor.applyMatch.mockResolvedValue({ kind: 'applied', imageUpdated: false, fieldsUpdated: [], downloads: 0 });
|
|
|
|
const result = await service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any);
|
|
|
|
expect(result.metadataCache).toEqual({ fallbackProviders: [served] });
|
|
});
|
|
|
|
it('answers 404 when the id matches nobody and leaves the author alone', async () => {
|
|
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate: null, failure: null, cacheServed: [], outboundRequests: 1 });
|
|
|
|
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '999' } as any)).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
expect(enrichmentExecutor.applyMatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers 503 when the provider fails rather than calling it a missing author', async () => {
|
|
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({
|
|
candidate: null,
|
|
failure: { provider: 'goodreads', message: 'HTTP 503', httpStatus: 503, retryAfterMs: null, transient: true },
|
|
cacheServed: [],
|
|
outboundRequests: 1,
|
|
});
|
|
|
|
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any)).rejects.toBeInstanceOf(
|
|
ServiceUnavailableException,
|
|
);
|
|
expect(enrichmentExecutor.applyMatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers 404 when the provider reports that the id does not exist', async () => {
|
|
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({
|
|
candidate: null,
|
|
failure: { provider: 'goodreads', message: 'HTTP 404', httpStatus: 404, retryAfterMs: null, transient: false },
|
|
cacheServed: [],
|
|
outboundRequests: 1,
|
|
});
|
|
|
|
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '999' } as any)).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
expect(enrichmentExecutor.applyMatch).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('answers 503 when the portrait download fails', async () => {
|
|
authorMetadataFetchService.lookupByIdDetailed.mockResolvedValue({ candidate, failure: null, cacheServed: [], outboundRequests: 1 });
|
|
enrichmentExecutor.applyMatch.mockResolvedValue({
|
|
kind: 'failed',
|
|
message: 'HTTP 429',
|
|
provider: 'goodreads',
|
|
httpStatus: 429,
|
|
retryAfterMs: 1000,
|
|
transient: true,
|
|
});
|
|
|
|
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any)).rejects.toBeInstanceOf(
|
|
ServiceUnavailableException,
|
|
);
|
|
});
|
|
|
|
it('checks library access before looking anything up', async () => {
|
|
libraryService.findEditableLibraryIds.mockResolvedValue([]);
|
|
|
|
await expect(service.pinMetadataMatch(reqUser(), 33, { provider: 'goodreads', providerId: '4567' } as any)).rejects.toBeInstanceOf(
|
|
ForbiddenException,
|
|
);
|
|
expect(authorMetadataFetchService.lookupByIdDetailed).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unpins without touching the metadata', async () => {
|
|
authorsRepo.updateAuthorById.mockResolvedValue({ id: 33 });
|
|
|
|
await service.unpinMetadataMatch(reqUser(), 33);
|
|
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(33, { metadataProviderPinned: false });
|
|
});
|
|
});
|
|
|
|
it('get image paths verify readability before touching storage', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([40]);
|
|
authorImageStorage.getThumbnailPath.mockResolvedValue('/tmp/thumb.jpg');
|
|
authorImageStorage.getImagePath.mockResolvedValue('/tmp/full.jpg');
|
|
|
|
await expect(service.getThumbnailPath(reqUser(), 40)).resolves.toBe('/tmp/thumb.jpg');
|
|
await expect(service.getImagePath(reqUser(), 40)).resolves.toBe('/tmp/full.jpg');
|
|
});
|
|
|
|
it('uploadImage validates and stores author image, then marks hasPhoto=true', async () => {
|
|
const bytes = Buffer.from('fake-image');
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([41]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorImageStorage.saveFromBuffer.mockResolvedValue(undefined);
|
|
authorsRepo.updateAuthorById.mockResolvedValue({ id: 41 });
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 41,
|
|
name: 'Photo Author',
|
|
sortName: null,
|
|
description: null,
|
|
bookCount: 1,
|
|
lastAddedAt: null,
|
|
});
|
|
authorImageStorage.getImageUrlIfExists.mockResolvedValue('/api/v1/authors/41/image?t=1234');
|
|
|
|
const result = await service.uploadImage(reqUser(), 41, bytes, 'image/png');
|
|
|
|
expect(authorImageStorage.saveFromBuffer).toHaveBeenCalledWith(41, bytes);
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(41, { hasPhoto: true });
|
|
expect(result).toEqual(expect.objectContaining({ id: 41, imageUrl: '/api/v1/authors/41/image?t=1234' }));
|
|
});
|
|
|
|
it('uploadImage rejects non-image mime types', async () => {
|
|
await expect(service.uploadImage(reqUser(), 41, Buffer.from('nope'), 'text/plain')).rejects.toMatchObject({
|
|
name: BadRequestException.name,
|
|
message: 'File must be an image',
|
|
});
|
|
});
|
|
|
|
it('uploadImage rejects files over 20 MB', async () => {
|
|
const tooLarge = Buffer.alloc(AuthorsService.MAX_AUTHOR_IMAGE_BYTES + 1);
|
|
await expect(service.uploadImage(reqUser(), 41, tooLarge, 'image/jpeg')).rejects.toMatchObject({
|
|
name: BadRequestException.name,
|
|
message: 'Image exceeds 20 MB limit',
|
|
});
|
|
});
|
|
|
|
it('deleteImage removes author image directory and marks hasPhoto=false', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([42]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorImageStorage.deleteAuthorDir.mockResolvedValue(undefined);
|
|
authorsRepo.updateAuthorById.mockResolvedValue({ id: 42 });
|
|
authorsRepo.findById.mockResolvedValue({
|
|
id: 42,
|
|
name: 'No Photo Author',
|
|
sortName: null,
|
|
description: null,
|
|
bookCount: 2,
|
|
lastAddedAt: null,
|
|
});
|
|
authorImageStorage.getImageUrlIfExists.mockResolvedValue(null);
|
|
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue(null);
|
|
|
|
const result = await service.deleteImage(reqUser(), 42);
|
|
|
|
expect(authorImageStorage.deleteAuthorDir).toHaveBeenCalledWith(42);
|
|
expect(authorsRepo.updateAuthorById).toHaveBeenCalledWith(42, { hasPhoto: false });
|
|
expect(result).toEqual(expect.objectContaining({ id: 42, imageUrl: null }));
|
|
});
|
|
|
|
it('bulkRefreshMetadata returns zero counters for empty input', async () => {
|
|
await expect(service.bulkRefreshMetadata([], reqUser())).resolves.toEqual({
|
|
processed: 0,
|
|
failed: 0,
|
|
updated: 0,
|
|
});
|
|
});
|
|
|
|
it('bulkRefreshMetadata stops iterating when progress callback throws', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue('https://cdn.example.com/1-thumb.jpg');
|
|
vi.spyOn(service as any, 'refreshEnrichmentInternal').mockResolvedValue({
|
|
descriptionUpdated: true,
|
|
imageUpdated: true,
|
|
provider: 'audnexus',
|
|
});
|
|
|
|
const progress = vi.fn(() => {
|
|
throw new Error('client disconnected');
|
|
});
|
|
|
|
const result = await service.bulkRefreshMetadata([1, 2], reqUser(), progress);
|
|
|
|
expect(result).toEqual({ processed: 1, failed: 0, updated: 1 });
|
|
expect(progress).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('bulkRefreshMetadata deduplicates ids and continues after per-item failures', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
vi.spyOn(service as any, 'refreshEnrichmentInternal')
|
|
.mockRejectedValueOnce(new Error('provider timeout'))
|
|
.mockResolvedValueOnce({
|
|
descriptionUpdated: false,
|
|
imageUpdated: true,
|
|
provider: 'audnexus',
|
|
});
|
|
vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
|
|
authorImageStorage.getThumbnailUrlIfExists.mockResolvedValue('https://cdn.example.com/2-thumb.jpg');
|
|
|
|
const progress = vi.fn();
|
|
const result = await service.bulkRefreshMetadata([1, 1, 2], reqUser(), progress);
|
|
|
|
expect(result).toEqual({ processed: 2, failed: 1, updated: 1 });
|
|
expect(progress).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
describe('bulkRefreshMetadata metadata cache', () => {
|
|
function doneResult(outboundRequests: number) {
|
|
return { kind: 'done', provider: 'audnexus', descriptionUpdated: true, imageUpdated: false, outboundRequests, cacheServed: [] };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2, 3]);
|
|
authorsRepo.findRelatedLibraryIds.mockResolvedValue([1]);
|
|
});
|
|
|
|
it('uses the cache with fallback by default', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([1]);
|
|
enrichmentExecutor.execute.mockResolvedValue(doneResult(1));
|
|
vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
|
|
|
|
await service.bulkRefreshMetadata([1], reqUser());
|
|
|
|
expect(enrichmentExecutor.execute).toHaveBeenCalledWith(expect.objectContaining({ authorId: 1 }), { policy: 'default', allowFallback: true });
|
|
});
|
|
|
|
it('goes live with fallback when asked to ignore the cache', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([1]);
|
|
enrichmentExecutor.execute.mockResolvedValue(doneResult(1));
|
|
vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
|
|
|
|
await service.bulkRefreshMetadata([1], reqUser(), undefined, { ignoreCache: true });
|
|
|
|
expect(enrichmentExecutor.execute).toHaveBeenCalledWith(expect.objectContaining({ authorId: 1 }), { policy: 'refresh', allowFallback: true });
|
|
});
|
|
|
|
it('only yields between items that made no outbound requests', async () => {
|
|
enrichmentExecutor.execute.mockResolvedValueOnce(doneResult(0)).mockResolvedValueOnce(doneResult(3)).mockResolvedValueOnce(doneResult(0));
|
|
const sleep = vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
|
|
|
|
await service.bulkRefreshMetadata([1, 2, 3], reqUser());
|
|
|
|
expect(sleep).toHaveBeenCalledTimes(2);
|
|
expect(sleep).toHaveBeenNthCalledWith(1, 100);
|
|
const providerDelay = sleep.mock.calls[1]![0] as number;
|
|
expect(providerDelay).toBeGreaterThanOrEqual(250);
|
|
expect(providerDelay).toBeLessThanOrEqual(1_000);
|
|
});
|
|
|
|
it('keeps the provider delay after a failed item', async () => {
|
|
authorsRepo.findVisibleAuthorIds.mockResolvedValue([1, 2]);
|
|
enrichmentExecutor.execute
|
|
.mockResolvedValueOnce({
|
|
kind: 'failed',
|
|
message: 'down',
|
|
provider: 'audnexus',
|
|
httpStatus: 503,
|
|
retryAfterMs: null,
|
|
transient: true,
|
|
descriptionUpdated: false,
|
|
imageUpdated: false,
|
|
outboundRequests: 0,
|
|
cacheServed: [],
|
|
})
|
|
.mockResolvedValueOnce(doneResult(0));
|
|
const sleep = vi.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
|
|
|
|
await service.bulkRefreshMetadata([1, 2], reqUser());
|
|
|
|
expect((sleep.mock.calls[0]![0] as number) >= 250).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('findJumpBuckets', () => {
|
|
it('turns letter counts into running offsets the rail can scroll to', async () => {
|
|
authorsRepo.findLetterCounts.mockResolvedValue([
|
|
{ letter: 'A', count: 42 },
|
|
{ letter: 'B', count: 14 },
|
|
{ letter: 'C', count: 35 },
|
|
]);
|
|
|
|
const response = await service.findJumpBuckets(reqUser(), {});
|
|
|
|
expect(response.kind).toBe('letter');
|
|
expect(response.granularity).toBeNull();
|
|
expect(response.total).toBe(91);
|
|
expect(response.buckets).toEqual([
|
|
{ key: 'A', label: 'A', index: 0, isUnknown: false },
|
|
{ key: 'B', label: 'B', index: 42, isUnknown: false },
|
|
{ key: 'C', label: 'C', index: 56, isUnknown: false },
|
|
]);
|
|
});
|
|
|
|
it('marks the non-alphabetic bucket as unknown', async () => {
|
|
authorsRepo.findLetterCounts.mockResolvedValue([
|
|
{ letter: '#', count: 3 },
|
|
{ letter: 'A', count: 5 },
|
|
]);
|
|
|
|
const response = await service.findJumpBuckets(reqUser(), {});
|
|
|
|
expect(response.buckets[0]).toEqual({ key: '#', label: '#', index: 0, isUnknown: true });
|
|
expect(response.buckets[1]?.index).toBe(3);
|
|
});
|
|
|
|
it('buckets by the same field the list is ordered by', async () => {
|
|
authorsRepo.findLetterCounts.mockResolvedValue([]);
|
|
|
|
await service.findJumpBuckets(reqUser(), { sort: 'sortName', order: 'desc' });
|
|
|
|
expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(expect.objectContaining({ sort: 'sortName', order: 'desc' }));
|
|
});
|
|
|
|
it('falls back to the display-name bucketing when no sort is given', async () => {
|
|
authorsRepo.findLetterCounts.mockResolvedValue([]);
|
|
|
|
await service.findJumpBuckets(reqUser(), {});
|
|
|
|
expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(expect.objectContaining({ sort: 'name', order: 'asc' }));
|
|
});
|
|
|
|
it('returns an empty rail when the user has no accessible libraries', async () => {
|
|
libraryService.findAll.mockResolvedValue([]);
|
|
libraryService.findAccessibleLibraryIds.mockResolvedValue([]);
|
|
|
|
const response = await service.findJumpBuckets(reqUser(), {});
|
|
|
|
expect(response).toEqual({ buckets: [], total: 0, kind: 'letter', granularity: null });
|
|
expect(authorsRepo.findLetterCounts).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('passes the list filters through so the rail matches what is on screen', async () => {
|
|
authorsRepo.findLetterCounts.mockResolvedValue([]);
|
|
|
|
await service.findJumpBuckets(reqUser(), { q: 'wei', hasPhoto: false, hasSortName: false, addedWithinDays: 7, minBookCount: 2 });
|
|
|
|
expect(authorsRepo.findLetterCounts).toHaveBeenCalledWith(
|
|
expect.objectContaining({ q: 'wei', hasPhoto: false, hasSortName: false, addedWithinDays: 7, minBookCount: 2 }),
|
|
);
|
|
});
|
|
});
|
|
});
|