fix(metadata): restore Hardcover title and author matching

Use Hardcover's default search configuration because custom fields currently return a payload-level error. Surface future search errors in logs so failures remain diagnosable.

Fixes #1123
This commit is contained in:
neon
2026-08-25 00:06:20 +00:00
parent 9989ba23e5
commit f2310c5806
2 changed files with 44 additions and 5 deletions
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EDITION_DISPLAY_LIMIT, HardcoverBookMatchService } from './hardcover-book-match.service';
@@ -37,6 +37,10 @@ describe('HardcoverBookMatchService', () => {
mockRepo.upsertBookState.mockResolvedValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('returns cached match when state exists and no error', async () => {
mockRepo.findBookState.mockResolvedValue({
hardcoverBookId: 100,
@@ -189,6 +193,32 @@ describe('HardcoverBookMatchService', () => {
expect.stringContaining('query SearchBooks'),
expect.objectContaining({ query: 'Test Book Test Author' }),
);
const searchQuery = mockClient.query.mock.calls[1]?.[2] as string;
expect(searchQuery).toContain('error');
expect(searchQuery).not.toContain('fields:');
expect(searchQuery).not.toContain('weights:');
});
it('logs payload-level errors returned by Hardcover search', async () => {
mockRepo.findBookState.mockResolvedValue(undefined);
mockClient.query.mockResolvedValueOnce({ books: [] }).mockResolvedValueOnce({
search: {
error: 'Number of values in `num_typos` does not match number of `query_by` fields.',
ids: null,
},
});
const service = makeService();
const logger = Reflect.get(service, 'logger') as { warn: (message: string) => void };
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
await expect(service.matchBook(1, 'tok', baseBook)).resolves.toBeNull();
expect(mockClient.query).toHaveBeenCalledTimes(2);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringMatching(
/^\[hardcover\.book_match\] \[fail\] userId=1 bookId=42 method=title_author durationMs=\d+ errorClass=SearchError error="Number of values in `num_typos` does not match number of `query_by` fields\." - title lookup failed$/,
),
);
});
it('preserves Hardcover search ranking when hydrating title matches', async () => {
@@ -46,9 +46,8 @@ query SearchBooks($query: String!) {
query_type: "Book"
per_page: 5
page: 1
fields: "title,author_names,alternative_titles"
weights: "5,2,1"
) {
error
ids
}
}`;
@@ -200,7 +199,8 @@ function mapEditionForDisplay(edition: HardcoverEditionDisplayRow): HardcoverEdi
interface SearchBooksResult {
search?: {
ids?: number[];
error?: string | null;
ids?: number[] | null;
} | null;
}
@@ -354,10 +354,18 @@ export class HardcoverBookMatchService {
author: string,
book: BookSyncData,
): Promise<HardcoverBookMatch | null> {
const startedAt = Date.now();
try {
const searchData = await this.client.query<SearchBooksResult>(userId, token, SEARCH_BOOKS_QUERY, {
query: `${title} ${author}`,
});
if (searchData.search?.error) {
const error = sanitizeLogValue(searchData.search.error);
this.logger.warn(
`[hardcover.book_match] [fail] userId=${userId} bookId=${book.bookId} method=title_author durationMs=${Date.now() - startedAt} errorClass=SearchError error="${error}" - title lookup failed`,
);
return null;
}
const ids = searchData.search?.ids?.filter((id) => Number.isInteger(id)).slice(0, 5) ?? [];
if (ids.length === 0) return null;
@@ -374,9 +382,10 @@ export class HardcoverBookMatchService {
matchMethod: 'title',
};
} catch (err) {
const errorClass = err instanceof Error ? err.constructor.name : 'Error';
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[hardcover.book_match] [fail] userId=${userId} bookId=${book.bookId} method=title_author error="${error}" - title lookup failed`,
`[hardcover.book_match] [fail] userId=${userId} bookId=${book.bookId} method=title_author durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${error}" - title lookup failed`,
);
return null;
}