mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(metadata): add hardcover edition selection and refresh pinning (#936)
* feat(metadata): add hardcover edition selection and refresh pinning Mirrors the StoryGraph edition picker for Hardcover, but backed by real GraphQL edition data instead of scraping. An edition can now be chosen either from the Hardcover settings page's new "Linked books" section or from the metadata downloader/refresh flow, and once chosen it pins future automatic metadata refreshes to that exact edition instead of silently re-deriving one by ISBN every time. book_metadata.hardcover_edition_id is shared across all users, while hardcover_book_state.hardcover_edition_id is scoped per (user, book). Cross-writing between them is one-directional and non-destructive per flow: the settings page always updates the acting user's own sync state and only back-fills the shared field if it is empty; the metadata downloader always updates the shared field and only updates the acting user's own sync state if they already have one. The propagation uses the existing achievement event bus rather than a direct module import, since HardcoverModule already depends on BookModule. * fix(metadata): address hardcover edition selection review findings Responds to CodeRabbit review on PR #936: - Surface Hardcover edition-lookup failures as errors instead of an empty list, so a provider outage cannot be mistaken for "no editions". Updates the client to throw and the UI to show a retryable error state. - Validate that a manually-picked edition id actually belongs to the book's matched Hardcover book before persisting it, rejecting mismatches with a 400 instead of trusting an unvalidated client input. - Require an exact safe-integer string before parsing a propagated hardcoverEditionId, so a value like "200abc" cannot be silently truncated into a valid-looking edition id. - Add durationMs to the edition-propagation failure log per the project's logging conventions. - Honor a pinned edition in the ISBN search() path too, not only in lookupById(), so a refresh that falls back to search still returns the previously chosen edition when it's present. * test(metadata): strengthen hardcover edition-pin fallback assertions The fallback tests for search() and lookupById() only used a single-edition fixture, so they would have passed even if the edition-pin filtering never ran. Use two-edition fixtures and assert on which edition is actually selected instead. * i18n(hardcover): use i18n keys for Linked books strings HardcoverLinkedBooks.vue hardcoded its UI text instead of using useI18n like its sibling components, breaking the ability to translate the Hardcover settings page and non-English catalogs. * fix(hardcover): harden edition selection and bound the linked-book list Validate a picked edition against the book it belongs to rather than against a capped, unordered list. Editions past the cap stay selectable, and two calls can no longer disagree about which window they saw. - Order the edition query by id and fetch one row past the cap, so truncation is detectable instead of silent. Verified against the live Hardcover API: both "The Name of the Wind" and "Pride and Prejudice" exceed the 50 cap. - Report truncation for the linked-book list and the edition list, and say so in the UI rather than presenting a partial list as complete. - Bound and order the linked-book query. It backed a settings list with the unbounded sync query; sync callers keep their previous unordered behaviour. - Throw for "not connected" and "not matched" instead of returning an empty list, which the UI could not tell apart from a genuinely empty catalogue. - Drop release_year from the edition query. Hardcover never populates it, so it was dead weight in the payload. - Add a tuned --success token. The status pill hardcoded text-green-600, which is untuned in dark mode; the light value is set for 4.5:1 on card and page. - Expose row expansion with aria-expanded and aria-controls, announce load failures through a live region, and keep the edition list open after a switch so the refreshed marker lands where the user is looking. --------- Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,7 @@ export const ACHIEVEMENT_EVENT_BACKFILL = 'achievement.backfill';
|
||||
export const ACHIEVEMENT_EVENT_ACHIEVEMENT_AWARDED = 'achievement.awarded';
|
||||
export const ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED = 'book.rating-changed';
|
||||
export const ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED = 'book.progress-changed';
|
||||
export const ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED = 'book.hardcover-edition-changed';
|
||||
|
||||
export interface ReadingSessionSavedPayload {
|
||||
userId: number;
|
||||
@@ -60,6 +61,12 @@ export interface BookProgressChangedPayload {
|
||||
source: 'koreader' | 'kobo' | 'web_reader';
|
||||
}
|
||||
|
||||
export interface BookHardcoverEditionChangedPayload {
|
||||
userId: number;
|
||||
bookId: number;
|
||||
hardcoverEditionId: string;
|
||||
}
|
||||
|
||||
export type AchievementEventPayload =
|
||||
| ReadingSessionSavedPayload
|
||||
| BookStatusChangedPayload
|
||||
@@ -67,7 +74,8 @@ export type AchievementEventPayload =
|
||||
| CollectionCreatedPayload
|
||||
| LibraryCatalogChangedPayload
|
||||
| BookRatingChangedPayload
|
||||
| BookProgressChangedPayload;
|
||||
| BookProgressChangedPayload
|
||||
| BookHardcoverEditionChangedPayload;
|
||||
|
||||
@Injectable()
|
||||
export class AchievementEventsService extends EventEmitter {}
|
||||
|
||||
+2
@@ -599,6 +599,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
goodreadsId: 'goodreads-1',
|
||||
amazonId: 'amazon-1',
|
||||
hardcoverId: 'hardcover-1',
|
||||
hardcoverEditionId: 'hardcover-edition-1',
|
||||
openLibraryId: 'open-library-1',
|
||||
itunesId: 'itunes-1',
|
||||
audibleId: 'audible-1',
|
||||
@@ -624,6 +625,7 @@ describe('BookMetadataFetchOrchestratorService', () => {
|
||||
author: undefined,
|
||||
isbn: 'isbn-10',
|
||||
isAudiobook: true,
|
||||
hardcoverEditionId: 'hardcover-edition-1',
|
||||
existingProviderIds: {
|
||||
[MetadataProviderKey.GOOGLE]: 'google-1',
|
||||
[MetadataProviderKey.GOODREADS]: 'goodreads-1',
|
||||
|
||||
@@ -224,6 +224,7 @@ export class BookMetadataFetchOrchestratorService implements OnApplicationBootst
|
||||
seriesName: meta?.seriesName ?? undefined,
|
||||
seriesIndex: meta?.seriesIndex ?? undefined,
|
||||
existingProviderIds: this.collectProviderIds(meta ?? {}),
|
||||
hardcoverEditionId: meta?.hardcoverEditionId ?? undefined,
|
||||
isAudiobook: (meta?.durationSeconds !== null && meta?.durationSeconds !== undefined) || !!meta?.audibleId || !!meta?.librofmId,
|
||||
maxCandidatesPerProvider: 1,
|
||||
};
|
||||
|
||||
@@ -72,6 +72,29 @@ describe('BookRepository', () => {
|
||||
expect(where).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('setHardcoverEditionIdIfEmpty only fills a missing shared edition id', async () => {
|
||||
const returning = vi.fn().mockResolvedValue([{ bookId: 5 }]);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
const db = { update: vi.fn().mockReturnValue({ set }) };
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
await expect(repo.setHardcoverEditionIdIfEmpty(5, '200')).resolves.toBe(true);
|
||||
|
||||
expect(db.update).toHaveBeenCalledWith(bookMetadata);
|
||||
expect(set).toHaveBeenCalledWith({ hardcoverEditionId: '200', updatedAt: expect.any(Date) });
|
||||
});
|
||||
|
||||
it('setHardcoverEditionIdIfEmpty returns false when the shared edition id is already set', async () => {
|
||||
const returning = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
const db = { update: vi.fn().mockReturnValue({ set }) };
|
||||
const repo = new BookRepository(db as never);
|
||||
|
||||
await expect(repo.setHardcoverEditionIdIfEmpty(5, '200')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('loads book titles for deletion audit details', async () => {
|
||||
const rows = [
|
||||
{ id: 3, title: 'Dune' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable, Optional } from '@nestjs/common';
|
||||
import { SQL, and, asc, count, eq, inArray, isNotNull, ne, or, sql } from 'drizzle-orm';
|
||||
import { SQL, and, asc, count, eq, inArray, isNotNull, isNull, ne, or, sql } from 'drizzle-orm';
|
||||
import { SUPPORTED_BOOK_FORMATS } from '../upload/upload-validator.service';
|
||||
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
@@ -1935,6 +1935,17 @@ export class BookRepository {
|
||||
await executor.update(books).set({ updatedAt: new Date() }).where(eq(books.id, bookId));
|
||||
}
|
||||
|
||||
// Only fills a missing shared edition id - never overwrites a value already set by someone
|
||||
// else (via metadata edit/refresh) so a per-user sync pick can't silently change shared metadata.
|
||||
async setHardcoverEditionIdIfEmpty(bookId: number, hardcoverEditionId: string): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.update(bookMetadata)
|
||||
.set({ hardcoverEditionId, updatedAt: new Date() })
|
||||
.where(and(eq(bookMetadata.bookId, bookId), isNull(bookMetadata.hardcoverEditionId)))
|
||||
.returning({ bookId: bookMetadata.bookId });
|
||||
return row != null;
|
||||
}
|
||||
|
||||
async updateAddedAt(bookId: number, addedAt: Date): Promise<void> {
|
||||
await this.db.update(books).set({ addedAt, updatedAt: new Date() }).where(eq(books.id, bookId));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../m
|
||||
import { parseFb2File } from '../metadata/lib/fb2-parser';
|
||||
import { parseMobiFile } from '../metadata/lib/mobi-parser';
|
||||
import { parsePdfFile } from '../metadata/lib/pdf-parser';
|
||||
import { ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
|
||||
import { ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
|
||||
import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import { BulkEditFieldsDto } from './dto/bulk-edit-metadata.dto';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
@@ -137,6 +137,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
|
||||
bulkSetRating: vi.fn(),
|
||||
bulkUpdateMetadataFields: vi.fn(),
|
||||
updateMetadataFields: vi.fn(),
|
||||
setHardcoverEditionIdIfEmpty: vi.fn(),
|
||||
updateAddedAt: vi.fn(),
|
||||
replaceCommunityRatings: vi.fn(),
|
||||
withTransaction: vi.fn(),
|
||||
@@ -1234,6 +1235,25 @@ describe('BookService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refreshMetadata pins the search to a previously chosen Hardcover edition', async () => {
|
||||
const { service, bookRepo, pipeline } = makeService();
|
||||
const user = makeUser();
|
||||
bookRepo.findById.mockResolvedValue({
|
||||
book: {
|
||||
books: { id: 1, libraryId: 7 },
|
||||
book_metadata: { title: 'Old', isbn13: '9780756404741', isbn10: null, hardcoverEditionId: '8941973' },
|
||||
},
|
||||
authorRows: [{ id: 1, name: 'Patrick Rothfuss', sortName: null }],
|
||||
genreRows: [],
|
||||
communityRatingRows: [],
|
||||
});
|
||||
pipeline.runWithSources.mockResolvedValue({ resolved: {}, sources: {}, providerIds: {} });
|
||||
|
||||
await service.refreshMetadata(1, true, user);
|
||||
|
||||
expect(pipeline.runWithSources).toHaveBeenCalledWith(expect.objectContaining({ hardcoverEditionId: '8941973' }), expect.any(Object), 7);
|
||||
});
|
||||
|
||||
it('refreshMetadata persists provider-specific community rating rows', async () => {
|
||||
const { service, bookRepo, pipeline } = makeService();
|
||||
const user = makeUser();
|
||||
@@ -1910,6 +1930,40 @@ describe('BookService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMetadata emits hardcover edition changed event when hardcoverEditionId is set', async () => {
|
||||
const { service, achievementEvents } = makeService();
|
||||
const user = makeUser();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
|
||||
|
||||
await service.updateMetadata(5, { hardcoverEditionId: '200' }, user);
|
||||
|
||||
expect(achievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, {
|
||||
userId: user.id,
|
||||
bookId: 5,
|
||||
hardcoverEditionId: '200',
|
||||
});
|
||||
});
|
||||
|
||||
it('updateMetadata does not emit hardcover edition changed event when hardcoverEditionId is cleared', async () => {
|
||||
const { service, achievementEvents } = makeService();
|
||||
const user = makeUser();
|
||||
vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
|
||||
vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
|
||||
|
||||
await service.updateMetadata(5, { hardcoverEditionId: null }, user);
|
||||
|
||||
expect(achievementEvents.emit).not.toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, expect.anything());
|
||||
});
|
||||
|
||||
it('setHardcoverEditionIdIfEmpty delegates to the repository', async () => {
|
||||
const { service, bookRepo } = makeService();
|
||||
bookRepo.setHardcoverEditionIdIfEmpty.mockResolvedValue(true);
|
||||
|
||||
await expect(service.setHardcoverEditionIdIfEmpty(5, '200')).resolves.toBe(true);
|
||||
expect(bookRepo.setHardcoverEditionIdIfEmpty).toHaveBeenCalledWith(5, '200');
|
||||
});
|
||||
|
||||
it('updateMetadata replaces genres array only', async () => {
|
||||
const { service, metadataService } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
@@ -81,7 +81,11 @@ import { FileWriteService } from '../file-write/file-write.service';
|
||||
import { NarratorService } from '../narrator/narrator.service';
|
||||
import { UserBookNoteService } from '../user-book-note/user-book-note.service';
|
||||
import { UserBookStatusService, type AutoReadingActivity } from '../user-book-status/user-book-status.service';
|
||||
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED } from '../achievement/achievement-events.service';
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
|
||||
AchievementEventsService,
|
||||
} from '../achievement/achievement-events.service';
|
||||
import { BookMetadataLockService } from '../book-metadata-lock/book-metadata-lock.service';
|
||||
import { BookQueryBuilder } from './book-query-builder.service';
|
||||
import { BookRepository } from './book.repository';
|
||||
@@ -487,6 +491,10 @@ export class BookService {
|
||||
}
|
||||
}
|
||||
|
||||
setHardcoverEditionIdIfEmpty(bookId: number, hardcoverEditionId: string): Promise<boolean> {
|
||||
return this.bookRepo.setHardcoverEditionIdIfEmpty(bookId, hardcoverEditionId);
|
||||
}
|
||||
|
||||
async verifyFileAccess(fileId: number, user: RequestUser): Promise<NonNullable<Awaited<ReturnType<BookRepository['findFileById']>>>> {
|
||||
const file = await this.bookRepo.findFileById(fileId);
|
||||
if (!file) throw new NotFoundException(`No file with id ${fileId}`);
|
||||
@@ -1795,6 +1803,15 @@ export class BookService {
|
||||
});
|
||||
}
|
||||
|
||||
// Clearing is intentionally not propagated, so it can't disrupt an unrelated active sync.
|
||||
if (dto.hardcoverEditionId != null) {
|
||||
this.achievementEvents?.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, {
|
||||
userId: user.id,
|
||||
bookId: id,
|
||||
hardcoverEditionId: dto.hardcoverEditionId,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMetadataUpdate) {
|
||||
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
|
||||
const hasRenameRelevantField =
|
||||
@@ -2661,6 +2678,7 @@ export class BookService {
|
||||
seriesName: meta?.seriesName ?? undefined,
|
||||
seriesIndex: meta?.seriesIndex ?? undefined,
|
||||
existingProviderIds: providerIds,
|
||||
hardcoverEditionId: meta?.hardcoverEditionId ?? undefined,
|
||||
isAudiobook: (meta?.durationSeconds !== null && meta?.durationSeconds !== undefined) || !!meta?.audibleId || !!meta?.librofmId,
|
||||
maxCandidatesPerProvider: 1,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArrayMaxSize, ArrayUnique, IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import type { ApplyHardcoverImportPayload, UpdateHardcoverBookSyncPayload } from '@bookorbit/types';
|
||||
import type { ApplyHardcoverImportPayload, SetHardcoverEditionPayload, UpdateHardcoverBookSyncPayload } from '@bookorbit/types';
|
||||
|
||||
export class UpsertHardcoverSettingsDto {
|
||||
@IsOptional()
|
||||
@@ -46,6 +46,12 @@ export class UpdateHardcoverBookSyncDto implements UpdateHardcoverBookSyncPayloa
|
||||
syncEnabled!: boolean;
|
||||
}
|
||||
|
||||
export class SetHardcoverEditionDto implements SetHardcoverEditionPayload {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
editionId!: number;
|
||||
}
|
||||
|
||||
export class ApplyHardcoverImportDto implements ApplyHardcoverImportPayload {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { HardcoverBookMatchService } from './hardcover-book-match.service';
|
||||
import { EDITION_DISPLAY_LIMIT, HardcoverBookMatchService } from './hardcover-book-match.service';
|
||||
|
||||
const mockRepo = {
|
||||
findBookState: vi.fn(),
|
||||
@@ -245,4 +245,133 @@ describe('HardcoverBookMatchService', () => {
|
||||
|
||||
expect(result).toEqual({ hardcoverBookId: 123, hardcoverEditionId: 801, editionPages: 405, matchMethod: 'title' });
|
||||
});
|
||||
|
||||
describe('listEditions', () => {
|
||||
it('maps editions for display, deriving a format label and published date', async () => {
|
||||
mockClient.query.mockResolvedValue({
|
||||
books: [
|
||||
{
|
||||
id: 100,
|
||||
editions: [
|
||||
{
|
||||
id: 200,
|
||||
title: 'Test Book',
|
||||
pages: 320,
|
||||
isbn_10: '0756404079',
|
||||
isbn_13: '9780756404079',
|
||||
publisher: { name: 'DAW Books' },
|
||||
language: { code2: 'en' },
|
||||
release_date: '2007-03-27',
|
||||
reading_format_id: 1,
|
||||
image: { url: 'https://assets.hardcover.app/cover.jpg' },
|
||||
},
|
||||
{
|
||||
id: 201,
|
||||
pages: null,
|
||||
reading_format_id: 2,
|
||||
audio_seconds: 50000,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await makeService().listEditions(1, 'tok', 100);
|
||||
|
||||
expect(result).toEqual({
|
||||
truncated: false,
|
||||
editions: [
|
||||
{
|
||||
id: 200,
|
||||
title: 'Test Book',
|
||||
format: 'Physical Book',
|
||||
pages: 320,
|
||||
isbn10: '0756404079',
|
||||
isbn13: '9780756404079',
|
||||
publisher: 'DAW Books',
|
||||
language: 'en',
|
||||
publishedDate: '2007-03-27',
|
||||
coverUrl: 'https://assets.hardcover.app/cover.jpg',
|
||||
},
|
||||
{
|
||||
id: 201,
|
||||
title: null,
|
||||
format: 'Audiobook',
|
||||
pages: null,
|
||||
isbn10: null,
|
||||
isbn13: null,
|
||||
publisher: null,
|
||||
language: null,
|
||||
publishedDate: null,
|
||||
coverUrl: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports no published date when the release date is missing', async () => {
|
||||
mockClient.query.mockResolvedValue({
|
||||
books: [{ id: 100, editions: [{ id: 200, release_date: null, reading_format_id: 1 }] }],
|
||||
});
|
||||
|
||||
const result = await makeService().listEditions(1, 'tok', 100);
|
||||
|
||||
expect(result.editions[0].publishedDate).toBeNull();
|
||||
});
|
||||
|
||||
it('requests a deterministic window so two calls agree on the same slice', async () => {
|
||||
mockClient.query.mockResolvedValue({ books: [] });
|
||||
await makeService().listEditions(1, 'tok', 100);
|
||||
|
||||
const query = mockClient.query.mock.calls[0][2] as string;
|
||||
expect(query).toContain('order_by: { id: asc }');
|
||||
});
|
||||
|
||||
it('reports truncation instead of silently dropping editions past the cap', async () => {
|
||||
const editions = Array.from({ length: EDITION_DISPLAY_LIMIT + 1 }, (_, index) => ({ id: index + 1, reading_format_id: 1 }));
|
||||
mockClient.query.mockResolvedValue({ books: [{ id: 100, editions }] });
|
||||
|
||||
const result = await makeService().listEditions(1, 'tok', 100);
|
||||
|
||||
expect(result.editions).toHaveLength(EDITION_DISPLAY_LIMIT);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty result when the book is not found', async () => {
|
||||
mockClient.query.mockResolvedValue({ books: [] });
|
||||
expect(await makeService().listEditions(1, 'tok', 999)).toEqual({ editions: [], truncated: false });
|
||||
});
|
||||
|
||||
it('throws instead of masking an upstream failure as an empty catalog', async () => {
|
||||
mockClient.query.mockRejectedValue(new Error('boom'));
|
||||
await expect(makeService().listEditions(1, 'tok', 100)).rejects.toThrow('Failed to load Hardcover editions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEditionForBook', () => {
|
||||
it('resolves an edition scoped to the book it must belong to', async () => {
|
||||
mockClient.query.mockResolvedValue({ books: [{ id: 100, editions: [{ id: 200, reading_format_id: 4 }] }] });
|
||||
|
||||
const result = await makeService().findEditionForBook(1, 'tok', 100, 200);
|
||||
|
||||
expect(result).toMatchObject({ id: 200, format: 'E-Book' });
|
||||
expect(mockClient.query).toHaveBeenCalledWith(1, 'tok', expect.stringContaining('FindBookEditionById'), { id: 100, editionId: 200 });
|
||||
});
|
||||
|
||||
it('resolves an edition that sits past the display cap', async () => {
|
||||
mockClient.query.mockResolvedValue({ books: [{ id: 100, editions: [{ id: 9999, reading_format_id: 1 }] }] });
|
||||
|
||||
await expect(makeService().findEditionForBook(1, 'tok', 100, 9999)).resolves.toMatchObject({ id: 9999 });
|
||||
});
|
||||
|
||||
it('returns null when the edition does not belong to the book', async () => {
|
||||
mockClient.query.mockResolvedValue({ books: [{ id: 100, editions: [] }] });
|
||||
expect(await makeService().findEditionForBook(1, 'tok', 100, 200)).toBeNull();
|
||||
});
|
||||
|
||||
it('throws instead of reporting an upstream failure as a rejected edition', async () => {
|
||||
mockClient.query.mockRejectedValue(new Error('boom'));
|
||||
await expect(makeService().findEditionForBook(1, 'tok', 100, 200)).rejects.toThrow('Failed to load Hardcover editions');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
|
||||
import type { HardcoverEdition as HardcoverEditionSummary, HardcoverEditionsResult } from '@bookorbit/types';
|
||||
|
||||
import { parsePublishedDateKey } from '../../common/utils/published-date.utils';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import type { BookSyncData } from './hardcover.repository';
|
||||
import { HardcoverClientService } from './hardcover-client.service';
|
||||
@@ -109,6 +111,90 @@ interface BooksQueryResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
// Hardcover reading_format_id: 1 = physical, 2 = audiobook, 4 = ebook.
|
||||
const AUDIOBOOK_READING_FORMAT_ID = 2;
|
||||
const EBOOK_READING_FORMAT_ID = 4;
|
||||
|
||||
export const EDITION_DISPLAY_LIMIT = 50;
|
||||
|
||||
const EDITION_DISPLAY_FIELDS = `
|
||||
id
|
||||
title
|
||||
pages
|
||||
isbn_10
|
||||
isbn_13
|
||||
publisher { name }
|
||||
language { code2 }
|
||||
release_date
|
||||
reading_format_id
|
||||
audio_seconds
|
||||
image { url }`;
|
||||
|
||||
// Ordered so the capped window is deterministic: without order_by, two calls can return a
|
||||
// different slice of the same catalogue and a legitimate pick would fail validation.
|
||||
// One over the cap, so the caller can tell a full page apart from a truncated one.
|
||||
const FIND_BOOK_EDITIONS_FOR_DISPLAY_QUERY = `
|
||||
query FindBookEditionsForDisplay($id: Int!) {
|
||||
books(where: { id: { _eq: $id } }, limit: 1) {
|
||||
id
|
||||
editions(limit: ${EDITION_DISPLAY_LIMIT + 1}, order_by: { id: asc }) {${EDITION_DISPLAY_FIELDS}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// Validates a single edition by asking for it scoped to the book, instead of re-listing the
|
||||
// capped window and checking membership - an edition past the cap is still a valid pick.
|
||||
const FIND_BOOK_EDITION_BY_ID_QUERY = `
|
||||
query FindBookEditionById($id: Int!, $editionId: Int!) {
|
||||
books(where: { id: { _eq: $id } }, limit: 1) {
|
||||
id
|
||||
editions(where: { id: { _eq: $editionId } }, limit: 1) {${EDITION_DISPLAY_FIELDS}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
interface HardcoverEditionDisplayRow {
|
||||
id: number;
|
||||
title?: string | null;
|
||||
pages?: number | null;
|
||||
isbn_10?: string | null;
|
||||
isbn_13?: string | null;
|
||||
publisher?: { name?: string | null } | null;
|
||||
language?: { code2?: string | null } | null;
|
||||
release_date?: string | null;
|
||||
reading_format_id?: number | null;
|
||||
audio_seconds?: number | null;
|
||||
image?: { url?: string | null } | null;
|
||||
}
|
||||
|
||||
interface BooksDisplayQueryResult {
|
||||
books: Array<{
|
||||
id: number;
|
||||
editions?: HardcoverEditionDisplayRow[];
|
||||
}>;
|
||||
}
|
||||
|
||||
function editionFormatLabel(edition: HardcoverEditionDisplayRow): string {
|
||||
if (edition.reading_format_id === AUDIOBOOK_READING_FORMAT_ID || (edition.audio_seconds ?? 0) > 0) return 'Audiobook';
|
||||
if (edition.reading_format_id === EBOOK_READING_FORMAT_ID) return 'E-Book';
|
||||
return 'Physical Book';
|
||||
}
|
||||
|
||||
function mapEditionForDisplay(edition: HardcoverEditionDisplayRow): HardcoverEditionSummary {
|
||||
return {
|
||||
id: edition.id,
|
||||
title: edition.title ?? null,
|
||||
format: editionFormatLabel(edition),
|
||||
pages: typeof edition.pages === 'number' && edition.pages > 0 ? edition.pages : null,
|
||||
isbn10: edition.isbn_10 ?? null,
|
||||
isbn13: edition.isbn_13 ?? null,
|
||||
publisher: edition.publisher?.name ?? null,
|
||||
language: edition.language?.code2 ?? null,
|
||||
publishedDate: parsePublishedDateKey(edition.release_date) ?? null,
|
||||
coverUrl: edition.image?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
interface SearchBooksResult {
|
||||
search?: {
|
||||
ids?: number[];
|
||||
@@ -288,6 +374,54 @@ export class HardcoverBookMatchService {
|
||||
}
|
||||
}
|
||||
|
||||
// A failed upstream lookup must not be reported as "no editions" - the caller (and the user)
|
||||
// needs to be able to tell an empty catalog apart from a provider outage and retry.
|
||||
async listEditions(userId: number, token: string, hardcoverBookId: number): Promise<HardcoverEditionsResult> {
|
||||
const data = await this.queryEditions(userId, token, hardcoverBookId, 'list_editions', FIND_BOOK_EDITIONS_FOR_DISPLAY_QUERY, {
|
||||
id: hardcoverBookId,
|
||||
});
|
||||
|
||||
const rows = data.books?.[0]?.editions ?? [];
|
||||
return {
|
||||
editions: rows.slice(0, EDITION_DISPLAY_LIMIT).map(mapEditionForDisplay),
|
||||
truncated: rows.length > EDITION_DISPLAY_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolves one edition scoped to the book it must belong to. Null means Hardcover knows of no
|
||||
// such edition on that book, which is exactly the "not yours to pick" case the caller rejects.
|
||||
async findEditionForBook(userId: number, token: string, hardcoverBookId: number, editionId: number): Promise<HardcoverEditionSummary | null> {
|
||||
const data = await this.queryEditions(userId, token, hardcoverBookId, 'find_edition', FIND_BOOK_EDITION_BY_ID_QUERY, {
|
||||
id: hardcoverBookId,
|
||||
editionId,
|
||||
});
|
||||
|
||||
const edition = data.books?.[0]?.editions?.[0];
|
||||
return edition ? mapEditionForDisplay(edition) : null;
|
||||
}
|
||||
|
||||
// A failed upstream lookup must not be reported as "no editions" - the caller (and the user)
|
||||
// needs to be able to tell an empty catalog apart from a provider outage and retry.
|
||||
private async queryEditions(
|
||||
userId: number,
|
||||
token: string,
|
||||
hardcoverBookId: number,
|
||||
event: string,
|
||||
query: string,
|
||||
variables: Record<string, unknown>,
|
||||
): Promise<BooksDisplayQueryResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
return await this.client.query<BooksDisplayQueryResult>(userId, token, query, variables);
|
||||
} catch (err) {
|
||||
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(
|
||||
`[hardcover.${event}] [fail] userId=${userId} hardcoverBookId=${hardcoverBookId} durationMs=${Date.now() - startedAt} errorClass=${err?.constructor?.name ?? 'Error'} error="${error}" - edition lookup failed`,
|
||||
);
|
||||
throw new InternalServerErrorException('Failed to load Hardcover editions');
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveCachedMatch(
|
||||
userId: number,
|
||||
token: string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
|
||||
@@ -9,15 +10,24 @@ import {
|
||||
} from '../achievement/achievement-events.service';
|
||||
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.service';
|
||||
import { HardcoverEventListener } from './hardcover-event-listener.service';
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
|
||||
const mockScheduler = {
|
||||
requestSync: vi.fn(),
|
||||
requestSyncForBookFile: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRepo = {
|
||||
updateEditionIfLinked: vi.fn(),
|
||||
};
|
||||
|
||||
function makeListener() {
|
||||
const events = new AchievementEventsService();
|
||||
const listener = new HardcoverEventListener(events, mockScheduler as unknown as HardcoverAutoSyncSchedulerService);
|
||||
const listener = new HardcoverEventListener(
|
||||
events,
|
||||
mockScheduler as unknown as HardcoverAutoSyncSchedulerService,
|
||||
mockRepo as unknown as HardcoverRepository,
|
||||
);
|
||||
listener.onModuleInit();
|
||||
return { events };
|
||||
}
|
||||
@@ -25,6 +35,7 @@ function makeListener() {
|
||||
describe('HardcoverEventListener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRepo.updateEditionIfLinked.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('schedules status auto-sync on status change', () => {
|
||||
@@ -76,4 +87,49 @@ describe('HardcoverEventListener', () => {
|
||||
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(2, { userId: 1, bookId: 2, reason: 'rating' });
|
||||
expect(mockScheduler.requestSync).toHaveBeenNthCalledWith(3, { userId: 1, bookId: 3, reason: 'rating' });
|
||||
});
|
||||
|
||||
it('propagates a hardcoverEditionId change to the user sync state', async () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, { userId: 1, bookId: 10, hardcoverEditionId: '200' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(mockRepo.updateEditionIfLinked).toHaveBeenCalledWith(1, 10, 200);
|
||||
});
|
||||
|
||||
it('ignores a non-numeric hardcoverEditionId', async () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, { userId: 1, bookId: 10, hardcoverEditionId: 'not-a-number' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(mockRepo.updateEditionIfLinked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores a partial numeric prefix like "200abc"', async () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, { userId: 1, bookId: 10, hardcoverEditionId: '200abc' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(mockRepo.updateEditionIfLinked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores exponent notation', async () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, { userId: 1, bookId: 10, hardcoverEditionId: '2e3' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(mockRepo.updateEditionIfLinked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores an unsafe integer value', async () => {
|
||||
const { events } = makeListener();
|
||||
|
||||
events.emit(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, { userId: 1, bookId: 10, hardcoverEditionId: '99999999999999999999' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(mockRepo.updateEditionIfLinked).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import {
|
||||
ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED,
|
||||
ACHIEVEMENT_EVENT_BOOK_STATUS_CHANGED,
|
||||
ACHIEVEMENT_EVENT_READING_SESSION_SAVED,
|
||||
AchievementEventsService,
|
||||
type BookHardcoverEditionChangedPayload,
|
||||
type BookProgressChangedPayload,
|
||||
type BookRatingChangedPayload,
|
||||
type BookStatusChangedPayload,
|
||||
type ReadingSessionSavedPayload,
|
||||
} from '../achievement/achievement-events.service';
|
||||
import { HardcoverAutoSyncSchedulerService } from './hardcover-auto-sync-scheduler.service';
|
||||
import { HardcoverRepository } from './hardcover.repository';
|
||||
|
||||
@Injectable()
|
||||
export class HardcoverEventListener implements OnModuleInit {
|
||||
private readonly logger = new Logger(HardcoverEventListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly achievementEvents: AchievementEventsService,
|
||||
private readonly scheduler: HardcoverAutoSyncSchedulerService,
|
||||
private readonly repo: HardcoverRepository,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -36,6 +43,10 @@ export class HardcoverEventListener implements OnModuleInit {
|
||||
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_RATING_CHANGED, (payload: BookRatingChangedPayload) => {
|
||||
this.handleRatingChanged(payload);
|
||||
});
|
||||
|
||||
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_HARDCOVER_EDITION_CHANGED, (payload: BookHardcoverEditionChangedPayload) => {
|
||||
void this.handleHardcoverEditionChanged(payload);
|
||||
});
|
||||
}
|
||||
|
||||
private handleStatusChanged(payload: BookStatusChangedPayload): void {
|
||||
@@ -55,4 +66,20 @@ export class HardcoverEventListener implements OnModuleInit {
|
||||
this.scheduler.requestSync({ userId: payload.userId, bookId, reason: 'rating' });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleHardcoverEditionChanged(payload: BookHardcoverEditionChangedPayload): Promise<void> {
|
||||
if (!/^\d+$/.test(payload.hardcoverEditionId)) return;
|
||||
const editionId = Number(payload.hardcoverEditionId);
|
||||
if (!Number.isSafeInteger(editionId)) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await this.repo.updateEditionIfLinked(payload.userId, payload.bookId, editionId);
|
||||
} catch (err) {
|
||||
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(
|
||||
`[hardcover.propagate_edition] [fail] userId=${payload.userId} bookId=${payload.bookId} durationMs=${Date.now() - startedAt} errorClass=${err?.constructor?.name ?? 'Error'} error="${error}" - propagating metadata edition id to sync state failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const mockRepo = {
|
||||
findSyncableBooks: vi.fn(),
|
||||
findSyncableBook: vi.fn(),
|
||||
findBookSyncData: vi.fn(),
|
||||
findCurrentReadingBooks: vi.fn(),
|
||||
findReadingAttempts: vi.fn(),
|
||||
linkReadingAttempt: vi.fn(),
|
||||
};
|
||||
@@ -22,6 +23,8 @@ const mockClient = {
|
||||
|
||||
const mockMatchService = {
|
||||
matchBook: vi.fn(),
|
||||
listEditions: vi.fn(),
|
||||
findEditionForBook: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSettingsService = {
|
||||
@@ -29,8 +32,12 @@ const mockSettingsService = {
|
||||
getSettings: vi.fn(),
|
||||
};
|
||||
|
||||
const mockBookService = {
|
||||
setHardcoverEditionIdIfEmpty: vi.fn(),
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
return new HardcoverSyncService(mockRepo as any, mockClient as any, mockMatchService as any, mockSettingsService as any);
|
||||
return new HardcoverSyncService(mockRepo as any, mockClient as any, mockMatchService as any, mockSettingsService as any, mockBookService as any);
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
@@ -67,12 +74,14 @@ describe('HardcoverSyncService', () => {
|
||||
mockRepo.findSyncableBooks.mockResolvedValue([]);
|
||||
mockRepo.findSyncableBook.mockResolvedValue(null);
|
||||
mockRepo.findBookSyncData.mockResolvedValue(null);
|
||||
mockRepo.findCurrentReadingBooks.mockResolvedValue([]);
|
||||
mockRepo.findReadingAttempts.mockResolvedValue([]);
|
||||
mockRepo.linkReadingAttempt.mockResolvedValue(undefined);
|
||||
mockRepo.upsertBookState.mockResolvedValue({});
|
||||
mockRepo.setBookSyncOverride.mockResolvedValue({});
|
||||
mockRepo.updateLastSyncedAt.mockResolvedValue(undefined);
|
||||
mockSettingsService.getSettings.mockResolvedValue(defaultSettings);
|
||||
mockBookService.setHardcoverEditionIdIfEmpty.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe('syncBook', () => {
|
||||
@@ -640,4 +649,162 @@ describe('HardcoverSyncService', () => {
|
||||
expect(svc.getSyncStatus(1)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listLinkedBooks', () => {
|
||||
it('maps currently-reading books with their link state', async () => {
|
||||
mockRepo.findCurrentReadingBooks.mockResolvedValue([readingBook]);
|
||||
mockRepo.findBookStatesByBookIds.mockResolvedValue([
|
||||
{ bookId: 1, hardcoverBookId: 100, hardcoverEditionId: 200, matchMethod: 'isbn', matchError: null },
|
||||
]);
|
||||
|
||||
const result = await makeService().listLinkedBooks(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
truncated: false,
|
||||
books: [
|
||||
{
|
||||
bookId: 1,
|
||||
title: 'Book One',
|
||||
authorName: 'Author One',
|
||||
hardcoverBookId: 100,
|
||||
hardcoverEditionId: 200,
|
||||
matchMethod: 'isbn',
|
||||
matchError: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports unlinked books without a matching state row', async () => {
|
||||
mockRepo.findCurrentReadingBooks.mockResolvedValue([readingBook]);
|
||||
mockRepo.findBookStatesByBookIds.mockResolvedValue([]);
|
||||
|
||||
const result = await makeService().listLinkedBooks(1);
|
||||
|
||||
expect(result.books).toEqual([
|
||||
{
|
||||
bookId: 1,
|
||||
title: 'Book One',
|
||||
authorName: 'Author One',
|
||||
hardcoverBookId: null,
|
||||
hardcoverEditionId: null,
|
||||
matchMethod: null,
|
||||
matchError: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('asks for one row past the cap so truncation can be detected', async () => {
|
||||
mockRepo.findCurrentReadingBooks.mockResolvedValue([readingBook]);
|
||||
mockRepo.findBookStatesByBookIds.mockResolvedValue([]);
|
||||
|
||||
await makeService().listLinkedBooks(1);
|
||||
|
||||
expect(mockRepo.findCurrentReadingBooks).toHaveBeenCalledWith(1, 201);
|
||||
});
|
||||
|
||||
it('caps the list and reports truncation instead of dropping books silently', async () => {
|
||||
const rows = Array.from({ length: 201 }, (_, index) => ({ ...readingBook, bookId: index + 1 }));
|
||||
mockRepo.findCurrentReadingBooks.mockResolvedValue(rows);
|
||||
mockRepo.findBookStatesByBookIds.mockResolvedValue([]);
|
||||
|
||||
const result = await makeService().listLinkedBooks(1);
|
||||
|
||||
expect(result.books).toHaveLength(200);
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEditions', () => {
|
||||
it('throws instead of reporting a disconnected account as an empty catalog', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue(null);
|
||||
await expect(makeService().getEditions(1, 1)).rejects.toThrow('Hardcover is not connected for this user');
|
||||
expect(mockMatchService.listEditions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws instead of reporting an unmatched book as an empty catalog', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: null });
|
||||
await expect(makeService().getEditions(1, 1)).rejects.toThrow('Book 1 is not matched to a Hardcover book yet');
|
||||
expect(mockMatchService.listEditions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists editions for the matched hardcover book', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: 100 });
|
||||
mockMatchService.listEditions.mockResolvedValue({ editions: [{ id: 200, format: 'Physical Book' }], truncated: false });
|
||||
|
||||
const result = await makeService().getEditions(1, 1);
|
||||
|
||||
expect(mockMatchService.listEditions).toHaveBeenCalledWith(1, 'tok', 100);
|
||||
expect(result).toEqual({ editions: [{ id: 200, format: 'Physical Book' }], truncated: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setEdition', () => {
|
||||
it('throws when the book has no matched hardcoverBookId', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockRepo.findBookState.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService().setEdition(1, 1, 200)).rejects.toThrow('Book 1 is not matched to a Hardcover book yet');
|
||||
expect(mockRepo.upsertBookState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when there is no token to validate the edition against', async () => {
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService().setEdition(1, 1, 200)).rejects.toThrow('Hardcover is not connected for this user');
|
||||
expect(mockRepo.upsertBookState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an edition id that does not belong to the matched hardcover book', async () => {
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: 100 });
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockMatchService.findEditionForBook.mockResolvedValue(null);
|
||||
|
||||
await expect(makeService().setEdition(1, 1, 200)).rejects.toThrow('Edition 200 does not belong to the matched Hardcover book');
|
||||
expect(mockRepo.upsertBookState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('validates against the book rather than the capped display window', async () => {
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: 100 });
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockMatchService.findEditionForBook.mockResolvedValue({ id: 9999, format: 'Physical Book' });
|
||||
|
||||
await expect(makeService().setEdition(1, 1, 9999)).resolves.toEqual({ success: true });
|
||||
expect(mockMatchService.findEditionForBook).toHaveBeenCalledWith(1, 'tok', 100, 9999);
|
||||
expect(mockMatchService.listEditions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets the edition, forces a resync, and back-fills the shared metadata field', async () => {
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: 100 });
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockMatchService.findEditionForBook.mockResolvedValue({ id: 200, format: 'Physical Book' });
|
||||
|
||||
const result = await makeService().setEdition(1, 1, 200);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockRepo.upsertBookState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 1,
|
||||
bookId: 1,
|
||||
hardcoverBookId: 100,
|
||||
hardcoverEditionId: 200,
|
||||
matchMethod: 'manual',
|
||||
matchError: null,
|
||||
lastSyncedAt: null,
|
||||
}),
|
||||
);
|
||||
expect(mockBookService.setHardcoverEditionIdIfEmpty).toHaveBeenCalledWith(1, '200');
|
||||
});
|
||||
|
||||
it('does not let a back-fill failure fail the edition pick', async () => {
|
||||
mockRepo.findBookState.mockResolvedValue({ hardcoverBookId: 100 });
|
||||
mockSettingsService.getTokenForUser.mockResolvedValue('tok');
|
||||
mockMatchService.findEditionForBook.mockResolvedValue({ id: 200, format: 'Physical Book' });
|
||||
mockBookService.setHardcoverEditionIdIfEmpty.mockRejectedValue(new Error('boom'));
|
||||
|
||||
await expect(makeService().setEdition(1, 1, 200)).resolves.toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type {
|
||||
HardcoverActiveSyncStatus,
|
||||
HardcoverBookSyncState,
|
||||
HardcoverEditionsResult,
|
||||
HardcoverLinkedBooksResult,
|
||||
HardcoverSettings,
|
||||
HardcoverSyncPendingSummary,
|
||||
ReadStatus,
|
||||
SetHardcoverEditionResult,
|
||||
UpdateHardcoverBookSyncPayload,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { distinctUntilChanged, filter, map, merge, Observable, of, Subject } from 'rxjs';
|
||||
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { HARDCOVER_STATUS } from './hardcover.constants';
|
||||
import { HardcoverBookMatchService } from './hardcover-book-match.service';
|
||||
import { HardcoverClientService } from './hardcover-client.service';
|
||||
@@ -22,6 +26,8 @@ import {
|
||||
resolveHardcoverBookSyncOverrideForToggle,
|
||||
} from './hardcover-sync-policy';
|
||||
|
||||
const LINKED_BOOKS_LIMIT = 200;
|
||||
|
||||
const STATUS_MAP: Partial<Record<ReadStatus, number>> = {
|
||||
want_to_read: HARDCOVER_STATUS.WANT_TO_READ,
|
||||
reading: HARDCOVER_STATUS.CURRENTLY_READING,
|
||||
@@ -121,6 +127,7 @@ export class HardcoverSyncService {
|
||||
private readonly client: HardcoverClientService,
|
||||
private readonly matchService: HardcoverBookMatchService,
|
||||
private readonly settingsService: HardcoverSettingsService,
|
||||
private readonly bookService: BookService,
|
||||
) {}
|
||||
|
||||
async syncBook(userId: number, bookId: number): Promise<HardcoverSyncBookResult> {
|
||||
@@ -277,6 +284,79 @@ export class HardcoverSyncService {
|
||||
return this.toBookSyncState(bookId, book, settings, state);
|
||||
}
|
||||
|
||||
async listLinkedBooks(userId: number): Promise<HardcoverLinkedBooksResult> {
|
||||
// One over the cap, so a full page can be told apart from a truncated one.
|
||||
const rows = await this.repo.findCurrentReadingBooks(userId, LINKED_BOOKS_LIMIT + 1);
|
||||
const books = rows.slice(0, LINKED_BOOKS_LIMIT);
|
||||
const states = await this.repo.findBookStatesByBookIds(
|
||||
userId,
|
||||
books.map((book) => book.bookId),
|
||||
);
|
||||
const stateByBookId = new Map(states.map((state) => [state.bookId, state]));
|
||||
|
||||
return {
|
||||
books: books.map((book) => {
|
||||
const state = stateByBookId.get(book.bookId);
|
||||
return {
|
||||
bookId: book.bookId,
|
||||
title: book.title,
|
||||
authorName: book.authorName,
|
||||
hardcoverBookId: state?.hardcoverBookId ?? null,
|
||||
hardcoverEditionId: state?.hardcoverEditionId ?? null,
|
||||
matchMethod: state?.matchMethod ?? null,
|
||||
matchError: state?.matchError ?? null,
|
||||
};
|
||||
}),
|
||||
truncated: rows.length > LINKED_BOOKS_LIMIT,
|
||||
};
|
||||
}
|
||||
|
||||
async getEditions(userId: number, bookId: number): Promise<HardcoverEditionsResult> {
|
||||
const { token, hardcoverBookId } = await this.requireMatchedBook(userId, bookId);
|
||||
return this.matchService.listEditions(userId, token, hardcoverBookId);
|
||||
}
|
||||
|
||||
async setEdition(userId: number, bookId: number, editionId: number): Promise<SetHardcoverEditionResult> {
|
||||
const { token, hardcoverBookId } = await this.requireMatchedBook(userId, bookId);
|
||||
|
||||
const edition = await this.matchService.findEditionForBook(userId, token, hardcoverBookId, editionId);
|
||||
if (!edition) {
|
||||
throw new BadRequestException(`Edition ${editionId} does not belong to the matched Hardcover book`);
|
||||
}
|
||||
|
||||
await this.repo.upsertBookState({
|
||||
userId,
|
||||
bookId,
|
||||
hardcoverBookId,
|
||||
hardcoverEditionId: editionId,
|
||||
matchMethod: 'manual',
|
||||
matchError: null,
|
||||
lastSyncedAt: null,
|
||||
});
|
||||
|
||||
await this.syncBook(userId, bookId);
|
||||
|
||||
await this.bookService.setHardcoverEditionIdIfEmpty(bookId, String(editionId)).catch((err) => {
|
||||
this.logger.warn(
|
||||
`[hardcover.set_edition] [fail] userId=${userId} bookId=${bookId} errorClass=${err?.constructor?.name ?? 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - back-fill of shared metadata edition id failed`,
|
||||
);
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Both edition routes need the same two preconditions, and neither can be reported as an empty
|
||||
// catalogue: "not connected" and "not matched yet" are distinct states the user can act on.
|
||||
private async requireMatchedBook(userId: number, bookId: number): Promise<{ token: string; hardcoverBookId: number }> {
|
||||
const token = await this.settingsService.getTokenForUser(userId);
|
||||
if (!token) throw new BadRequestException('Hardcover is not connected for this user');
|
||||
|
||||
const state = await this.repo.findBookState(userId, bookId);
|
||||
if (!state?.hardcoverBookId) throw new BadRequestException(`Book ${bookId} is not matched to a Hardcover book yet`);
|
||||
|
||||
return { token, hardcoverBookId: state.hardcoverBookId };
|
||||
}
|
||||
|
||||
private async runSyncAll(userId: number, token: string, books: BookSyncData[], runId: number, settings: HardcoverSettings): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let synced = 0;
|
||||
|
||||
@@ -19,6 +19,9 @@ const mockSyncService = {
|
||||
getBookSyncState: vi.fn(),
|
||||
updateBookSyncState: vi.fn(),
|
||||
syncBook: vi.fn(),
|
||||
listLinkedBooks: vi.fn(),
|
||||
getEditions: vi.fn(),
|
||||
setEdition: vi.fn(),
|
||||
};
|
||||
|
||||
const mockImportService = {
|
||||
@@ -177,6 +180,29 @@ describe('HardcoverController', () => {
|
||||
expect(mockSyncService.getBookSyncState).toHaveBeenCalledWith(1, 42);
|
||||
});
|
||||
|
||||
it('listLinkedBooks delegates to service', async () => {
|
||||
mockSyncService.listLinkedBooks.mockResolvedValue({ books: [{ bookId: 1, title: 'Book One' }], truncated: false });
|
||||
const result = await makeController().listLinkedBooks(mockUser as any);
|
||||
expect(result).toEqual({ books: [{ bookId: 1, title: 'Book One' }], truncated: false });
|
||||
expect(mockSyncService.listLinkedBooks).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('listEditions delegates to service after verifying book access', async () => {
|
||||
mockSyncService.getEditions.mockResolvedValue({ editions: [{ id: 200, format: 'Physical Book' }], truncated: false });
|
||||
const result = await makeController().listEditions(mockUser as any, 42);
|
||||
expect(result).toEqual({ editions: [{ id: 200, format: 'Physical Book' }], truncated: false });
|
||||
expect(mockBookService.verifyBookAccess).toHaveBeenCalledWith(42, mockUser);
|
||||
expect(mockSyncService.getEditions).toHaveBeenCalledWith(1, 42);
|
||||
});
|
||||
|
||||
it('setEdition delegates to service after verifying book access', async () => {
|
||||
mockSyncService.setEdition.mockResolvedValue({ success: true });
|
||||
const result = await makeController().setEdition(mockUser as any, 42, { editionId: 200 });
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(mockBookService.verifyBookAccess).toHaveBeenCalledWith(42, mockUser);
|
||||
expect(mockSyncService.setEdition).toHaveBeenCalledWith(1, 42, 200);
|
||||
});
|
||||
|
||||
it('previewImport delegates to service', async () => {
|
||||
mockImportService.previewImport.mockResolvedValue({ summary: { willUpdate: 1 }, rows: [] });
|
||||
const result = await makeController().previewImport(mockUser as any);
|
||||
|
||||
@@ -6,7 +6,13 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { BookService } from '../book/book.service';
|
||||
import { ApplyHardcoverImportDto, UpdateHardcoverBookSyncDto, UpsertHardcoverSettingsDto, ValidateHardcoverTokenDto } from './dto';
|
||||
import {
|
||||
ApplyHardcoverImportDto,
|
||||
SetHardcoverEditionDto,
|
||||
UpdateHardcoverBookSyncDto,
|
||||
UpsertHardcoverSettingsDto,
|
||||
ValidateHardcoverTokenDto,
|
||||
} from './dto';
|
||||
import { HardcoverImportService } from './hardcover-import.service';
|
||||
import { HardcoverSettingsService } from './hardcover-settings.service';
|
||||
import { HardcoverSyncService } from './hardcover-sync.service';
|
||||
@@ -66,6 +72,23 @@ export class HardcoverController {
|
||||
return this.syncService.getSyncPendingSummary(user.id);
|
||||
}
|
||||
|
||||
@Get('books')
|
||||
listLinkedBooks(@CurrentUser() user: RequestUser) {
|
||||
return this.syncService.listLinkedBooks(user.id);
|
||||
}
|
||||
|
||||
@Get('books/:bookId/editions')
|
||||
async listEditions(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
return this.syncService.getEditions(user.id, bookId);
|
||||
}
|
||||
|
||||
@Patch('books/:bookId/edition')
|
||||
async setEdition(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number, @Body() dto: SetHardcoverEditionDto) {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
return this.syncService.setEdition(user.id, bookId, dto.editionId);
|
||||
}
|
||||
|
||||
@Get('books/:bookId/sync-state')
|
||||
async getBookSyncState(@CurrentUser() user: RequestUser, @Param('bookId', ParseIntPipe) bookId: number) {
|
||||
await this.bookService.verifyBookAccess(bookId, user);
|
||||
|
||||
@@ -210,6 +210,59 @@ describe('HardcoverRepository', () => {
|
||||
expect(selectArgs.some((cols) => cols && 'bookId' in cols && 'status' in cols)).toBe(true);
|
||||
});
|
||||
|
||||
it('findCurrentReadingBooks filters to reading/rereading statuses and bounds the result', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ['from', 'innerJoin', 'leftJoin', 'where', 'groupBy', 'as', 'orderBy', 'limit']) {
|
||||
chain[method] = vi.fn().mockReturnValue(chain);
|
||||
}
|
||||
chain.then = (resolve: (rows: unknown[]) => void) => resolve([{ bookId: 42 }]);
|
||||
db.select.mockImplementation(() => chain);
|
||||
|
||||
await expect(repo.findCurrentReadingBooks(7, 200)).resolves.toEqual([{ bookId: 42 }]);
|
||||
expect(chain.where).toHaveBeenCalled();
|
||||
expect(chain.orderBy).toHaveBeenCalled();
|
||||
expect(chain.limit).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('leaves the unbounded sync query without an order or limit', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ['from', 'innerJoin', 'leftJoin', 'where', 'groupBy', 'as', 'orderBy', 'limit']) {
|
||||
chain[method] = vi.fn().mockReturnValue(chain);
|
||||
}
|
||||
chain.then = (resolve: (rows: unknown[]) => void) => resolve([{ bookId: 42 }]);
|
||||
db.select.mockImplementation(() => chain);
|
||||
|
||||
await repo.findSyncableBooks(7);
|
||||
|
||||
expect(chain.orderBy).not.toHaveBeenCalled();
|
||||
expect(chain.limit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updateEditionIfLinked updates an existing link and returns true', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const returning = vi.fn().mockResolvedValue([{ id: 2 }]);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
db.update.mockReset();
|
||||
db.update.mockReturnValue({ set });
|
||||
|
||||
await expect(repo.updateEditionIfLinked(7, 42, 555)).resolves.toBe(true);
|
||||
expect(set).toHaveBeenCalledWith(expect.objectContaining({ hardcoverEditionId: 555 }));
|
||||
});
|
||||
|
||||
it('updateEditionIfLinked returns false when the user has no link for that book', async () => {
|
||||
const { repo, db } = makeRepository();
|
||||
const returning = vi.fn().mockResolvedValue([]);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
db.update.mockReset();
|
||||
db.update.mockReturnValue({ set });
|
||||
|
||||
await expect(repo.updateEditionIfLinked(7, 42, 555)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('findBookIdByFileId returns null when no file row exists', async () => {
|
||||
const { repo, bookIdLimit } = makeRepository();
|
||||
bookIdLimit.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
import type { ContentFilterRules, ReadStatus } from '@bookorbit/types';
|
||||
import { and, eq, inArray, ne, sql } from 'drizzle-orm';
|
||||
import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
|
||||
import { buildContentFilterClauses } from '../../common/utils/content-filter-sql.utils';
|
||||
@@ -118,6 +118,17 @@ export class HardcoverRepository {
|
||||
return row!;
|
||||
}
|
||||
|
||||
// Only updates an existing link (never creates one) - used to propagate a metadata-side edition
|
||||
// pick into a user's sync target without turning an unrelated metadata edit into a new sync link.
|
||||
async updateEditionIfLinked(userId: number, bookId: number, hardcoverEditionId: number): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.update(schema.hardcoverBookState)
|
||||
.set({ hardcoverEditionId, updatedAt: new Date() })
|
||||
.where(and(eq(schema.hardcoverBookState.userId, userId), eq(schema.hardcoverBookState.bookId, bookId)))
|
||||
.returning({ id: schema.hardcoverBookState.id });
|
||||
return row != null;
|
||||
}
|
||||
|
||||
async setBookSyncOverride(userId: number, bookId: number, syncOverride: 'included' | 'excluded' | null): Promise<HardcoverBookState> {
|
||||
const syncExcluded = syncOverride === 'excluded';
|
||||
const [row] = await this.db
|
||||
@@ -153,11 +164,22 @@ export class HardcoverRepository {
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
// Bounded and ordered: this backs a settings list, not a sync run, and a library can hold far
|
||||
// more in-progress books than anyone wants rendered at once.
|
||||
async findCurrentReadingBooks(userId: number, limit: number): Promise<BookSyncData[]> {
|
||||
return this.findBookSyncDataForUser(userId, undefined, false, { statuses: ['reading', 'rereading'], limit });
|
||||
}
|
||||
|
||||
private async findSyncableBooksForUser(userId: number, bookId?: number): Promise<BookSyncData[]> {
|
||||
return this.findBookSyncDataForUser(userId, bookId, false);
|
||||
}
|
||||
|
||||
private async findBookSyncDataForUser(userId: number, bookId?: number, includeUnread = true): Promise<BookSyncData[]> {
|
||||
private async findBookSyncDataForUser(
|
||||
userId: number,
|
||||
bookId?: number,
|
||||
includeUnread = true,
|
||||
options: { statuses?: ReadStatus[]; limit?: number } = {},
|
||||
): Promise<BookSyncData[]> {
|
||||
const bookFilter = bookId !== undefined ? eq(schema.books.id, bookId) : undefined;
|
||||
|
||||
const maxProgressSq = this.db
|
||||
@@ -219,7 +241,14 @@ export class HardcoverRepository {
|
||||
.leftJoin(attemptsSq, eq(attemptsSq.bookId, schema.books.id))
|
||||
.leftJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.books.primaryFileId));
|
||||
|
||||
const rows = includeUnread ? await query.where(bookFilter) : await query.where(and(bookFilter, ne(schema.userBookStatus.status, 'unread')));
|
||||
const statusFilter = options.statuses ? inArray(schema.userBookStatus.status, options.statuses) : ne(schema.userBookStatus.status, 'unread');
|
||||
const filtered = includeUnread ? query.where(bookFilter) : query.where(and(bookFilter, statusFilter));
|
||||
|
||||
// Sync callers stay unordered and unbounded; only the bounded list needs a stable window.
|
||||
const rows =
|
||||
options.limit === undefined
|
||||
? await filtered
|
||||
: await filtered.orderBy(asc(schema.bookMetadata.title), asc(schema.books.id)).limit(options.limit);
|
||||
|
||||
return rows as BookSyncData[];
|
||||
}
|
||||
|
||||
@@ -133,6 +133,40 @@ describe('HardcoverProvider', () => {
|
||||
expect(results[1].pageCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns only the pinned edition from an ISBN search when hardcoverEditionId is provided', async () => {
|
||||
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([
|
||||
{
|
||||
...mockBook,
|
||||
editions: [
|
||||
{ id: 1001, isbn_13: '9780756404079', reading_format_id: 1 },
|
||||
{ id: 1002, isbn_13: '9780756404079', reading_format_id: 2 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await provider.search({ isbn: '9780756404079', hardcoverEditionId: '1002' });
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].hardcoverEditionId).toBe('1002');
|
||||
});
|
||||
|
||||
it('returns all candidates from an ISBN search when the pinned edition is not among them', async () => {
|
||||
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([
|
||||
{
|
||||
...mockBook,
|
||||
editions: [
|
||||
{ id: 1001, isbn_13: '9780756404079', reading_format_id: 1 },
|
||||
{ id: 1002, isbn_13: '9780756404079', reading_format_id: 2 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await provider.search({ isbn: '9780756404079', hardcoverEditionId: '999999' });
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results.map((result) => result.hardcoverEditionId)).toEqual(expect.arrayContaining(['1001', '1002']));
|
||||
});
|
||||
|
||||
it('falls through to title search when ISBN returns no results', async () => {
|
||||
vi.spyOn(client, 'searchByIsbn').mockResolvedValue([]);
|
||||
vi.spyOn(client, 'searchBooks').mockResolvedValue([mockDocument]);
|
||||
@@ -305,5 +339,39 @@ describe('HardcoverProvider', () => {
|
||||
vi.spyOn(client, 'lookupBySlug').mockResolvedValue({ ...mockBook, editions: [] });
|
||||
expect(await provider.lookupById('the-name-of-the-wind')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the pinned edition when hardcoverEditionId is provided, even if ISBN would match a different one', async () => {
|
||||
vi.spyOn(client, 'lookupBySlug').mockResolvedValue({
|
||||
...mockBook,
|
||||
editions: [
|
||||
{ id: 1001, isbn_13: '9780756404079', reading_format_id: 1, pages: 662 },
|
||||
{ id: 1002, isbn_13: '9780756404079', reading_format_id: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await provider.lookupById('the-name-of-the-wind', undefined, {
|
||||
isbn: '9780756404079',
|
||||
hardcoverEditionId: '1002',
|
||||
});
|
||||
|
||||
expect(result?.hardcoverEditionId).toBe('1002');
|
||||
});
|
||||
|
||||
it('falls back to ISBN match when the pinned edition id is no longer present', async () => {
|
||||
vi.spyOn(client, 'lookupBySlug').mockResolvedValue({
|
||||
...mockBook,
|
||||
editions: [
|
||||
{ id: 1001, isbn_13: 'OTHER-ISBN', reading_format_id: 1 },
|
||||
{ id: 1002, isbn_13: '9780756404079', reading_format_id: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await provider.lookupById('the-name-of-the-wind', undefined, {
|
||||
isbn: '9780756404079',
|
||||
hardcoverEditionId: '999999',
|
||||
});
|
||||
|
||||
expect(result?.hardcoverEditionId).toBe('1002');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,11 @@ export class HardcoverProvider implements IdentifiableProvider {
|
||||
if (params.isbn) {
|
||||
const books = signal ? await this.client.searchByIsbn(params.isbn, apiKey, signal) : await this.client.searchByIsbn(params.isbn, apiKey);
|
||||
if (books.length > 0) {
|
||||
return books.flatMap(mapBookWithEditions);
|
||||
const candidates = books.flatMap(mapBookWithEditions);
|
||||
const pinned = params.hardcoverEditionId
|
||||
? candidates.find((candidate) => candidate.hardcoverEditionId === params.hardcoverEditionId)
|
||||
: undefined;
|
||||
return pinned ? [pinned] : candidates;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +61,12 @@ export class HardcoverProvider implements IdentifiableProvider {
|
||||
if (!book) return null;
|
||||
|
||||
const candidates = mapBookWithEditions(book);
|
||||
|
||||
if (params?.hardcoverEditionId) {
|
||||
const pinned = candidates.find((candidate) => candidate.hardcoverEditionId === params.hardcoverEditionId);
|
||||
if (pinned) return pinned;
|
||||
}
|
||||
|
||||
const requestedIsbn = normalizeMetadataIsbn(params?.isbn);
|
||||
if (requestedIsbn) {
|
||||
return candidates.find((candidate) => candidateHasNormalizedIsbn(candidate, requestedIsbn)) ?? null;
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface MetadataSearchParams {
|
||||
seriesName?: string;
|
||||
seriesIndex?: number;
|
||||
existingProviderIds?: Partial<Record<MetadataProviderKey, string>>;
|
||||
// Pins a Hardcover refresh to a previously chosen edition instead of re-deriving one by ISBN.
|
||||
hardcoverEditionId?: string;
|
||||
isAudiobook?: boolean;
|
||||
// Hint for providers to cap deep candidate exploration in non-interactive flows
|
||||
// (e.g. auto-fill/background refresh where there is no manual candidate picking).
|
||||
|
||||
Reference in New Issue
Block a user