Files
bookorbit/server/src/modules/authors/authors.service.test.ts
T
neon 2b8917afb6 feat(authors): allow correcting metadata matches
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
2026-10-20 16:05:32 -06:00

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 }),
);
});
});
});